From 6f516cfac017bd52fc209b6bc07530db8ffe851b Mon Sep 17 00:00:00 2001 From: Gotcha26 Date: Sun, 8 Feb 2026 13:51:18 +0100 Subject: [PATCH 01/51] Replace single apostrophes with double quotation marks. This allows the chain text to be captured for translation. --- piwigoPublish.lrplugin/CustomMetadata.lua | 14 +++++++------- piwigoPublish.lrplugin/Info.lua | 4 ++-- piwigoPublish.lrplugin/PWExtraOptions.lua | 2 +- piwigoPublish.lrplugin/PublishDialogSections.lua | 8 ++++---- 4 files changed, 14 insertions(+), 14 deletions(-) diff --git a/piwigoPublish.lrplugin/CustomMetadata.lua b/piwigoPublish.lrplugin/CustomMetadata.lua index d4e7ace..7333e52 100644 --- a/piwigoPublish.lrplugin/CustomMetadata.lua +++ b/piwigoPublish.lrplugin/CustomMetadata.lua @@ -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,7 +86,7 @@ return { searchable = false, browsable = false, id = 'pwCommentSync', - title = 'pwCommentSync', + title = "pwCommentSync", version = 1 }, } diff --git a/piwigoPublish.lrplugin/Info.lua b/piwigoPublish.lrplugin/Info.lua index 3d80f50..331e896 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/PWExtraOptions.lua b/piwigoPublish.lrplugin/PWExtraOptions.lua index efd94d5..3a64da7 100644 --- a/piwigoPublish.lrplugin/PWExtraOptions.lua +++ b/piwigoPublish.lrplugin/PWExtraOptions.lua @@ -96,7 +96,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/PublishDialogSections.lua b/piwigoPublish.lrplugin/PublishDialogSections.lua index 4a2019a..7f2d48b 100644 --- a/piwigoPublish.lrplugin/PublishDialogSections.lua +++ b/piwigoPublish.lrplugin/PublishDialogSections.lua @@ -191,7 +191,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 +228,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", font = "", width = share 'buttonwidth', enabled = bind('Connected', propertyTable), @@ -264,7 +264,7 @@ local function prefsDialog(f, propertyTable) f:spacer { height = 1 }, f:row { f:push_button { - title = 'Clone Existing Publish Service', + title = "Clone Existing Publish Service", font = "", width = share 'buttonwidth', enabled = bind('Connected', propertyTable), @@ -296,7 +296,7 @@ local function prefsDialog(f, propertyTable) f:row { f:push_button { - title = 'Create Special Collections', + title = "Create Special Collections", font = "", width = share 'buttonwidth', enabled = bind('Connected', propertyTable), From 51134c3a6bf50b642fecd1350c23a18bbaab7a7f Mon Sep 17 00:00:00 2001 From: Gotcha26 Date: Sun, 8 Feb 2026 21:46:12 +0100 Subject: [PATCH 02/51] fix: #49 Proposal for better layout on Externam Module Manager --- .../PluginInfoDialogSections.lua | 278 +++++++++++++----- 1 file changed, 203 insertions(+), 75 deletions(-) diff --git a/piwigoPublish.lrplugin/PluginInfoDialogSections.lua b/piwigoPublish.lrplugin/PluginInfoDialogSections.lua index 2cf0658..e9ac4ea 100644 --- a/piwigoPublish.lrplugin/PluginInfoDialogSections.lua +++ b/piwigoPublish.lrplugin/PluginInfoDialogSections.lua @@ -2,7 +2,7 @@ PluginInfoDialogSections.lua - Publish Dialog Sections for Piwigo Publisher plugin + Plugin Manager Dialog Sections for Piwigo Publisher plugin Copyright (C) 2024 Fiona Boston . @@ -27,6 +27,15 @@ local LrHttp = import 'LrHttp' PluginInfoDialogSections = {} -- ************************************************* +-- CONSTANTS +-- ************************************************* +local GITHUB_URL = "https://github.com/Piwigo/PiwigoPublish-lrc-plugin" + +-- ************************************************* +-- HELPER FUNCTIONS +-- ************************************************* +-- Reset plugin preferences (optionally filtered by prefix) +-- NOTE: Currently not exposed in the GUI but kept for potential future use local function resetPluginPrefs(prefix) log:info("resetPluginPrefs \n" .. utils.serialiseVar(prefs)) for k, p in prefs:pairs() do @@ -40,30 +49,32 @@ local function resetPluginPrefs(prefix) end end - +-- ************************************************* +-- DIALOG LIFECYCLE -- ************************************************* function PluginInfoDialogSections.startDialog(propertyTable) -- Initialize update status propertyTable.updateStatus = UpdateChecker.getUpdateStatus() - + + -- Initialize debug preferences if prefs.debugEnabled == nil then prefs.debugEnabled = false end - if prefs.debugToFile == nil then - prefs.debugToFile = false + + -- Initialize update check preference + if prefs.checkUpdatesOnStartup == nil then + prefs.checkUpdatesOnStartup = true end + + -- Apply debug settings if prefs.debugEnabled then - if prefs.debugToFile then - log:enable("logfile") - else - log:enable("print") - end + log:enable("logfile") else log:disable() end - + propertyTable.debugEnabled = prefs.debugEnabled - propertyTable.debugToFile = prefs.debugToFile + propertyTable.checkUpdatesOnStartup = prefs.checkUpdatesOnStartup end -- ************************************************* @@ -72,109 +83,229 @@ function PluginInfoDialogSections.sectionsForBottomOfDialog(f, propertyTable) local share = LrView.share return { - + -- =================================== + -- SELF UPDATE SECTION + -- =================================== { bind_to_object = propertyTable, + title = "Self Update", - title = "Plugin Updates", + f:row { + f:checkbox { + value = bind 'checkUpdatesOnStartup', + title = "Check for updates to this plugin when Lightroom starts", + }, + }, f:row { - f:static_text { - title = "Current version: " .. pluginVersion, - alignment = 'left', + f:push_button { + title = "Check for updates now", + action = function() + UpdateChecker.checkForUpdates(false) -- silent = false + end, }, }, + }, + + -- =================================== + -- DEBUGGING SECTION + -- =================================== + { + bind_to_object = propertyTable, + title = "Debugging", + f:row { f:static_text { - title = bind 'updateStatus', + title = "If you have a problem with Piwigo Publisher then I'll probably ask you to activate the debug logging. This will save all sorts of useful information into a file.", + width_in_chars = 60, + height_in_lines = 2, alignment = 'left', }, }, + f:row { - f:push_button { - title = "Check for Updates", - action = function() - UpdateChecker.checkForUpdates(false) -- silent = false - end, + spacing = f:label_spacing(), + + f:radio_button { + value = bind 'debugEnabled', + checked_value = false, + title = "Do not log debug information", + }, + }, + + f:row { + spacing = f:label_spacing(), + + f:radio_button { + value = bind 'debugEnabled', + checked_value = true, + title = "Log debug information to a file (PiwigoPublishPlugin.log) in your Lightroom logs folder", }, + }, + + f:row { + spacing = f:label_spacing(), + f:push_button { - title = "Visit GitHub Repository", + title = "Show logfile", + enabled = bind 'debugEnabled', action = function() - LrHttp.openUrlInBrowser( - "https://github.com/" .. - UpdateChecker.GITHUB_OWNER .. "/" .. - UpdateChecker.GITHUB_REPO - ) + LrShell.revealInShell(utils.getLogfilePath()) end, }, }, }, + -- =================================== + -- STATUS SECTION + -- =================================== { bind_to_object = propertyTable, + title = "Status", + + f:row { + f:column { + f:picture { + alignment = 'left', + value = iconPath, + }, + }, + f:column { + spacing = f:control_spacing(), + + f:static_text { + title = "Piwigo Publisher", + alignment = 'left', + font = "", + }, + + f:row { + f:static_text { + title = "Version:", + alignment = 'right', + width = share 'label_width', + }, + f:static_text { + title = pluginVersion, + alignment = 'left', + }, + }, + + f:row { + f:static_text { + title = "Update Status:", + alignment = 'right', + width = share 'label_width', + }, + f:static_text { + title = bind 'updateStatus', + alignment = 'left', + }, + }, + + f:row { + f:static_text { + title = "Plugin page:", + alignment = 'right', + width = share 'label_width', + }, + f:column { + f:static_text { + title = GITHUB_URL, + alignment = 'left', + text_color = LrColor("blue"), + mouse_down = function() + LrHttp.openUrlInBrowser(GITHUB_URL) + end, + }, + f:push_button { + title = "Visit...", + action = function() + LrHttp.openUrlInBrowser(GITHUB_URL) + end, + }, + }, + }, + }, + }, + }, - title = "Piwigo Publisher Plugin Logging", + -- =================================== + -- ACKNOWLEDGEMENTS SECTION + -- =================================== + { + bind_to_object = propertyTable, + title = "Acknowledgements", f:row { - f:checkbox { - value = bind 'debugEnabled', + f:static_text { + title = "Developer:", + alignment = 'right', + width = share 'ack_label_width', + font = "", }, f:static_text { - title = "Enable debug logging", + title = "Fiona Boston", alignment = 'left', - width = share 'labelWidth' }, }, + f:row { - f:checkbox { - value = bind 'debugToFile', - enabled = LrView.bind("debugEnabled"), -- only allow if debug is enabled - }, + f:spacer { width = share 'ack_label_width' }, f:static_text { - title = "Log to file instead of console", - alignment = 'right', - width = share 'labelWidth' - }, - f:push_button { - title = "Show logfile", - enabled = LrView.bind("debugEnabled"), -- only allow if debug is enabled - action = function(button) - LrShell.revealInShell(utils.getLogfilePath()) + title = "fiona@fbphotography.uk", + alignment = 'left', + text_color = LrColor("blue"), + mouse_down = function() + LrHttp.openUrlInBrowser("mailto:fiona@fbphotography.uk") end, }, }, + f:row { f:static_text { - enabled = LrView.bind("debugToFile"), - title = utils.getLogfilePath(), + title = string.rep("─", 70), + alignment = 'left', + text_color = LrColor("black"), }, }, - f:row { - spacing = f:control_spacing(), - f:push_button { - title = "Reset Plugin Preferences…", + f:row { + f:static_text { + title = "Contributor:", + alignment = 'right', + width = share 'ack_label_width', + font = "", + }, + f:static_text { + title = "Julien Moreau", + alignment = 'left', + }, + }, - action = function() - local result = LrDialogs.confirm( - "Reset Plugin Preferences", - "This will delete all saved settings for this plugin.\n\nThis cannot be undone.", - "Reset", - "Cancel" - ) - - if result == "ok" then - resetPluginPrefs() - LrDialogs.message( - "Preferences Reset", - "Plugin preferences have been cleared.", - "info" - ) - end + f:row { + f:spacer { width = share 'ack_label_width' }, + f:static_text { + title = "contact@julien-moreau.fr", + alignment = 'left', + text_color = LrColor("blue"), + mouse_down = function() + LrHttp.openUrlInBrowser("mailto:contact@julien-moreau.fr") end, }, }, + f:row { + f:spacer { width = share 'ack_label_width' }, + f:static_text { + title = "https://julien-moreau.fr", + alignment = 'left', + text_color = LrColor("blue"), + mouse_down = function() + LrHttp.openUrlInBrowser("https://julien-moreau.fr") + end, + }, + }, }, } end @@ -182,14 +313,11 @@ end -- ************************************************* function PluginInfoDialogSections.endDialog(propertyTable) prefs.debugEnabled = propertyTable.debugEnabled - prefs.debugToFile = propertyTable.debugToFile + prefs.checkUpdatesOnStartup = propertyTable.checkUpdatesOnStartup + -- Apply debug settings if prefs.debugEnabled then - if prefs.debugToFile then - log:enable("logfile") - else - log:enable("print") - end + log:enable("logfile") else log:disable() end From 2149329242b7f27bb89f220cf90e0c7e49be4420 Mon Sep 17 00:00:00 2001 From: Gotcha26 Date: Sun, 8 Feb 2026 23:03:29 +0100 Subject: [PATCH 03/51] feat: #12 Keywords filters --- .../PublishDialogSections.lua | 53 +++ .../PublishServiceProvider.lua | 2 + piwigoPublish.lrplugin/PublishTask.lua | 387 +++++++++++++----- piwigoPublish.lrplugin/utils.lua | 85 ++++ 4 files changed, 415 insertions(+), 112 deletions(-) diff --git a/piwigoPublish.lrplugin/PublishDialogSections.lua b/piwigoPublish.lrplugin/PublishDialogSections.lua index 7f2d48b..e1e25c2 100644 --- a/piwigoPublish.lrplugin/PublishDialogSections.lua +++ b/piwigoPublish.lrplugin/PublishDialogSections.lua @@ -410,6 +410,59 @@ local function prefsDialog(f, propertyTable) value = bind 'KwSynonyms', } }, + + f:spacer { height = 2 }, + f:separator { fill_horizontal = 1 }, + f:spacer { height = 2 }, + + 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 = "", + }, + f:spacer { height = 2 }, + + f:row { + fill_horizontal = 1, + spacing = f:control_spacing(), + + f:column { + f:static_text { + title = "Exclusion Rules", + font = "", + }, + f:edit_field { + value = bind 'KwFilterExclude', + font = "", + alignment = 'left', + width_in_chars = 30, + height_in_lines = 8, + tooltip = "Photos with any keyword matching these rules will not be published. One rule per line.", + }, + }, + + f:column { + f:static_text { + title = "Inclusion Rules", + font = "", + }, + f:edit_field { + value = bind 'KwFilterInclude', + font = "", + alignment = 'left', + width_in_chars = 30, + height_in_lines = 8, + tooltip = "Photos must have at least one keyword matching these rules to be published. Leave empty to allow all. One rule per line.", + }, + }, + }, }, f:spacer { height = 2 }, f:group_box { diff --git a/piwigoPublish.lrplugin/PublishServiceProvider.lua b/piwigoPublish.lrplugin/PublishServiceProvider.lua index 0e57923..5055ed7 100644 --- a/piwigoPublish.lrplugin/PublishServiceProvider.lua +++ b/piwigoPublish.lrplugin/PublishServiceProvider.lua @@ -57,6 +57,8 @@ return { { key = "syncAlbumDescriptions", default = false }, { key = "syncCommentsPublish", default = true }, { key = "syncCommentsPubOnly", default = false }, + { key = "KwFilterInclude", default = '' }, + { key = "KwFilterExclude", default = '' }, }, metadataThatTriggersRepublish = function(publishSettings, photoId, fieldName) diff --git a/piwigoPublish.lrplugin/PublishTask.lua b/piwigoPublish.lrplugin/PublishTask.lua index 8f83180..de40a05 100644 --- a/piwigoPublish.lrplugin/PublishTask.lua +++ b/piwigoPublish.lrplugin/PublishTask.lua @@ -133,6 +133,25 @@ function PublishTask.processRenderedPhotos(functionContext, exportContext) 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, @@ -162,139 +181,161 @@ function PublishTask.processRenderedPhotos(functionContext, exportContext) local lrPhoto = rendition.photo local remoteId = rendition.publishedPhotoId or "" - -- 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") - - log:info("DEBUG multi-album: remoteId vide, checking metadata...") - log:info("DEBUG storedHost: " .. tostring(storedHost)) - log:info("DEBUG storedImageUrl: " .. tostring(storedImageUrl)) - log:info("DEBUG propertyTable.host: " .. tostring(propertyTable.host)) - - if storedHost == propertyTable.host and storedImageUrl then - existingPwImageId = utils.extractPwImageIdFromUrl(storedImageUrl, propertyTable.host) + -- Keyword filter check + local kwBlocked = false + if 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("Blocked by keyword filter: " .. reason) + kwBlocked = true end + end - -- Method 2: Search in other collections of the service (fallback) - if not existingPwImageId then - log:info("DEBUG multi-album: metadata vides, recherche cross-collection...") - local publishService = publishedCollection:getService() - existingPwImageId = utils.findExistingPwImageId(publishService, lrPhoto) - if existingPwImageId then - log:info("DEBUG multi-album: trouvé via cross-collection, ID = " .. tostring(existingPwImageId)) + if not kwBlocked 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") + + log:info("DEBUG multi-album: remoteId vide, checking metadata...") + log:info("DEBUG storedHost: " .. tostring(storedHost)) + log:info("DEBUG storedImageUrl: " .. tostring(storedImageUrl)) + log:info("DEBUG propertyTable.host: " .. tostring(propertyTable.host)) + + if storedHost == propertyTable.host and storedImageUrl then + existingPwImageId = utils.extractPwImageIdFromUrl(storedImageUrl, propertyTable.host) end - end - -- Verify the image still exists on Piwigo - if existingPwImageId then - local checkStatus = PiwigoAPI.checkPhoto(propertyTable, existingPwImageId) - if not checkStatus.status then - log:info("DEBUG multi-album: image " .. existingPwImageId .. " n'existe plus sur Piwigo") - existingPwImageId = nil + -- Method 2: Search in other collections of the service (fallback) + if not existingPwImageId then + log:info("DEBUG multi-album: metadata vides, recherche cross-collection...") + local publishService = publishedCollection:getService() + existingPwImageId = utils.findExistingPwImageId(publishService, lrPhoto) + if existingPwImageId then + log:info("DEBUG multi-album: trouvé via cross-collection, ID = " .. tostring(existingPwImageId)) + end 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) + -- Verify the image still exists on Piwigo + if existingPwImageId then + local checkStatus = PiwigoAPI.checkPhoto(propertyTable, existingPwImageId) + if not checkStatus.status then + log:info("DEBUG multi-album: image " .. existingPwImageId .. " n'existe plus sur Piwigo") + existingPwImageId = nil + end + end 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) + -- 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) - else - log:warn("Association failed: " .. (callStatus.statusMsg or "") .. ", falling back to upload") - existingPwImageId = nil end + break 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" + 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 - -- store / update custom metadata + 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) + 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) + -- 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 - 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 + -- 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 + progressScope:done() PWStatusManager.setPiwigoBusy(publishService, false) end @@ -797,6 +838,12 @@ function PublishTask.viewForCollectionSettings(f, publishSettings, info) if collectionSettings.KwSynonyms == nil then collectionSettings.KwSynonyms = true end + if collectionSettings.KwFilterInclude == nil then + collectionSettings.KwFilterInclude = "" + end + if collectionSettings.KwFilterExclude == nil then + collectionSettings.KwFilterExclude = "" + end -- build UI local reSizeOptions = { { title = "Long Edge", value = "Long Edge" }, @@ -927,9 +974,64 @@ function PublishTask.viewForCollectionSettings(f, publishSettings, info) }, } + local kwFilterUI = f:group_box { + title = "Keyword Filtering (Overrides defaults set in Publish Settings)", + font = "", + size = 'regular', + fill_horizontal = 1, + bind_to_object = assert(collectionSettings), + f:column { + spacing = f:control_spacing(), + fill_horizontal = 1, + f:separator { fill_horizontal = 1 }, + f:static_text { + title = "One rule per line. Use * to match any characters, ? to match a single character.", + font = "", + }, + f:static_text { + title = "Leave empty to use global settings from Publish Settings.", + font = "", + }, + f:spacer { height = 2 }, + f:row { + fill_horizontal = 1, + spacing = f:control_spacing(), + f:column { + f:static_text { + title = "Exclusion Rules", + font = "", + }, + f:edit_field { + value = bind 'KwFilterExclude', + font = "", + alignment = 'left', + width_in_chars = 25, + height_in_lines = 6, + tooltip = "Photos with any keyword matching these rules will not be published. One rule per line.", + }, + }, + f:column { + f:static_text { + title = "Inclusion Rules", + font = "", + }, + f:edit_field { + value = bind 'KwFilterInclude', + font = "", + alignment = 'left', + width_in_chars = 25, + height_in_lines = 6, + tooltip = "Photos must have at least one keyword matching these rules to be published. Leave empty to allow all. One rule per line.", + }, + }, + }, + }, + } + local UI = f:column { spacing = f:control_spacing(), pwAlbumUI, + kwFilterUI, --pubSettingsUI, } return UI @@ -1089,6 +1191,12 @@ function PublishTask.viewForCollectionSetSettings(f, publishSettings, info) if collectionSettings.KwSynonyms == nil then collectionSettings.KwSynonyms = true end + if collectionSettings.KwFilterInclude == nil then + collectionSettings.KwFilterInclude = "" + end + if collectionSettings.KwFilterExclude == nil then + collectionSettings.KwFilterExclude = "" + end -- build UI local reSizeOptions = { { title = "Long Edge", value = "Long Edge" }, @@ -1141,9 +1249,64 @@ function PublishTask.viewForCollectionSetSettings(f, publishSettings, info) } } + local kwFilterUI = f:group_box { + title = "Keyword Filtering (Overrides defaults set in Publish Settings)", + font = "", + size = 'regular', + fill_horizontal = 1, + bind_to_object = assert(collectionSettings), + f:column { + spacing = f:control_spacing(), + fill_horizontal = 1, + f:separator { fill_horizontal = 1 }, + f:static_text { + title = "One rule per line. Use * to match any characters, ? to match a single character.", + font = "", + }, + f:static_text { + title = "Leave empty to use global settings from Publish Settings.", + font = "", + }, + f:spacer { height = 2 }, + f:row { + fill_horizontal = 1, + spacing = f:control_spacing(), + f:column { + f:static_text { + title = "Exclusion Rules", + font = "", + }, + f:edit_field { + value = bind 'KwFilterExclude', + font = "", + alignment = 'left', + width_in_chars = 25, + height_in_lines = 6, + tooltip = "Photos with any keyword matching these rules will not be published. One rule per line.", + }, + }, + f:column { + f:static_text { + title = "Inclusion Rules", + font = "", + }, + f:edit_field { + value = bind 'KwFilterInclude', + font = "", + alignment = 'left', + width_in_chars = 25, + height_in_lines = 6, + tooltip = "Photos must have at least one keyword matching these rules to be published. Leave empty to allow all. One rule per line.", + }, + }, + }, + }, + } + local UI = f:column { spacing = f:control_spacing(), pwAlbumUI, + kwFilterUI, --pubSettingsUI, } return UI diff --git a/piwigoPublish.lrplugin/utils.lua b/piwigoPublish.lrplugin/utils.lua index b1bf35b..4517874 100644 --- a/piwigoPublish.lrplugin/utils.lua +++ b/piwigoPublish.lrplugin/utils.lua @@ -494,6 +494,91 @@ function utils.BuildTagString(propertyTable, lrPhoto) return tagString end +-- ************************************************* +function utils.wildcardMatch(pattern, text) + -- match text against a wildcard pattern (* = any chars, ? = single char) + -- case-insensitive + local p = pattern:lower() + local t = text:lower() + -- escape Lua magic characters except * and ? + p = p:gsub("([%.%+%-%^%$%(%)%%])", "%%%1") + p = p:gsub("%[", "%%[") + p = p:gsub("%]", "%%]") + -- convert wildcards to Lua patterns + p = p:gsub("%*", ".*") + p = p:gsub("%?", ".") + -- anchor the pattern + p = "^" .. p .. "$" + return t:match(p) ~= nil +end + +-- ************************************************* +function utils.parseFilterPatterns(filterString) + -- parse filter patterns string into a table (one rule per line, also accepts commas) + local patterns = {} + if not filterString or filterString == "" then + return patterns + end + for token in filterString:gmatch("[^\r\n,]+") do + local trimmed = utils.clean_spaces(token) + if trimmed ~= "" then + table.insert(patterns, trimmed) + end + end + return patterns +end + +-- ************************************************* +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 keywords +end + +-- ************************************************* +function utils.checkKeywordFilter(keywords, includePatterns, excludePatterns) + -- check if a list of keywords satisfies include/exclude filter rules + -- returns: isAllowed (bool), failReason (string or nil) + + -- 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 + end + + -- 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 true, nil +end + -- ************************************************* function utils.getPhotoMetadata(publishSettings, lrPhoto) -- build set of metadata to be send to Piwigo From 8c6351efe83ae2e549ee02960e50bb95e05ae379 Mon Sep 17 00:00:00 2001 From: Gotcha26 Date: Mon, 9 Feb 2026 00:59:07 +0100 Subject: [PATCH 04/51] Layout and factoring --- .../PublishDialogSections.lua | 109 +-------- piwigoPublish.lrplugin/PublishTask.lua | 182 +------------- piwigoPublish.lrplugin/UIHelpers.lua | 230 ++++++++++++++++++ 3 files changed, 244 insertions(+), 277 deletions(-) diff --git a/piwigoPublish.lrplugin/PublishDialogSections.lua b/piwigoPublish.lrplugin/PublishDialogSections.lua index e1e25c2..b5a6a8a 100644 --- a/piwigoPublish.lrplugin/PublishDialogSections.lua +++ b/piwigoPublish.lrplugin/PublishDialogSections.lua @@ -228,7 +228,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), @@ -252,19 +252,19 @@ local function prefsDialog(f, propertyTable) end, }, f:static_text { - title = "Piwigo structure will be checked against local collection / set structure. Missing Piwigo albums will be created and links checked / updated", + title = "Piwigo structure will be checked against local collection / set structure.\nMissing Piwigo albums will be created and links checked / updated", font = "", 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 +283,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 +296,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 +320,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 }, @@ -374,96 +374,7 @@ local function prefsDialog(f, propertyTable) f:spacer { height = 2 }, - f:group_box { - title = "Keyword Settings", - font = "", - fill_horizontal = 1, - f:spacer { height = 2 }, - f:row { - fill_horizontal = 1, - f:static_text { - title = "", - alignment = 'right', - width_in_chars = 7, - }, - f:checkbox { - font = "", - title = "Include Full Keyword Hierarchy", - tooltip = "If checked, all keywords in a keyword hierarchy will be sent to Piwigo", - value = bind 'KwFullHierarchy', - } - }, - - f:spacer { height = 2 }, - - f:row { - fill_horizontal = 1, - f:static_text { - title = "", - alignment = 'right', - width_in_chars = 7, - }, - f:checkbox { - font = "", - title = "Include Keyword Synonyms", - tooltip = "If checked, keyword synonyms will be sent to Piwigo", - value = bind 'KwSynonyms', - } - }, - - f:spacer { height = 2 }, - f:separator { fill_horizontal = 1 }, - f:spacer { height = 2 }, - - 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 = "", - }, - f:spacer { height = 2 }, - - f:row { - fill_horizontal = 1, - spacing = f:control_spacing(), - - f:column { - f:static_text { - title = "Exclusion Rules", - font = "", - }, - f:edit_field { - value = bind 'KwFilterExclude', - font = "", - alignment = 'left', - width_in_chars = 30, - height_in_lines = 8, - tooltip = "Photos with any keyword matching these rules will not be published. One rule per line.", - }, - }, - - f:column { - f:static_text { - title = "Inclusion Rules", - font = "", - }, - f:edit_field { - value = bind 'KwFilterInclude', - font = "", - alignment = 'left', - width_in_chars = 30, - height_in_lines = 8, - tooltip = "Photos must have at least one keyword matching these rules to be published. Leave empty to allow all. One rule per line.", - }, - }, - }, - }, + UIHelpers.createKeywordSettingsGroupBox(f, bind), f:spacer { height = 2 }, f:group_box { title = "Other Settings", diff --git a/piwigoPublish.lrplugin/PublishTask.lua b/piwigoPublish.lrplugin/PublishTask.lua index de40a05..99f9046 100644 --- a/piwigoPublish.lrplugin/PublishTask.lua +++ b/piwigoPublish.lrplugin/PublishTask.lua @@ -860,43 +860,7 @@ function PublishTask.viewForCollectionSettings(f, publishSettings, info) { title = "All Except Camera & Camera Raw Info", value = "All Except Camera & Camera Raw Info" }, } - local pwAlbumUI = f:group_box { - title = "Piwigo Album Settings", - font = "", - size = 'regular', - fill_horizontal = 1, - bind_to_object = assert(collectionSettings), - f:column { - spacing = f:control_spacing(), - - f:separator { fill_horizontal = 1 }, - - f:row { - f:static_text { title = "Album Description:", font = "", alignment = 'right', width = share 'label_width', }, - f:edit_field { - enabled = LrView.bind { - key = 'syncAlbumDescriptions', - object = publishSettings, - }, - - value = bind 'albumDescription', - width_in_chars = 40, - font = "", - alignment = 'left', - height_in_lines = 4, - }, - }, - - f:row { - f:checkbox { - title = "Album is Private", - tooltip = "If checked, this album will be private on Piwigo", - value = bind 'albumPrivate', - } - } - - } - } + local pwAlbumUI = UIHelpers.createPiwigoAlbumSettingsUI(f, share, bind, collectionSettings, publishSettings) local pubSettingsUI = f:group_box { title = "Custom Publish Settings (Overrides defaults set in Publish Settings)", @@ -974,59 +938,7 @@ function PublishTask.viewForCollectionSettings(f, publishSettings, info) }, } - local kwFilterUI = f:group_box { - title = "Keyword Filtering (Overrides defaults set in Publish Settings)", - font = "", - size = 'regular', - fill_horizontal = 1, - bind_to_object = assert(collectionSettings), - f:column { - spacing = f:control_spacing(), - fill_horizontal = 1, - f:separator { fill_horizontal = 1 }, - f:static_text { - title = "One rule per line. Use * to match any characters, ? to match a single character.", - font = "", - }, - f:static_text { - title = "Leave empty to use global settings from Publish Settings.", - font = "", - }, - f:spacer { height = 2 }, - f:row { - fill_horizontal = 1, - spacing = f:control_spacing(), - f:column { - f:static_text { - title = "Exclusion Rules", - font = "", - }, - f:edit_field { - value = bind 'KwFilterExclude', - font = "", - alignment = 'left', - width_in_chars = 25, - height_in_lines = 6, - tooltip = "Photos with any keyword matching these rules will not be published. One rule per line.", - }, - }, - f:column { - f:static_text { - title = "Inclusion Rules", - font = "", - }, - f:edit_field { - value = bind 'KwFilterInclude', - font = "", - alignment = 'left', - width_in_chars = 25, - height_in_lines = 6, - tooltip = "Photos must have at least one keyword matching these rules to be published. Leave empty to allow all. One rule per line.", - }, - }, - }, - }, - } + local kwFilterUI = UIHelpers.createKeywordFilteringUI(f, bind, collectionSettings) local UI = f:column { spacing = f:control_spacing(), @@ -1213,95 +1125,9 @@ function PublishTask.viewForCollectionSetSettings(f, publishSettings, info) { title = "All Except Camera & Camera Raw Info", value = "All Except Camera & Camera Raw Info" }, } - local pwAlbumUI = f:group_box { - title = "Piwigo Album Settings", - font = "", - size = 'regular', - fill_horizontal = 1, - bind_to_object = assert(collectionSettings), - f:column { - spacing = f:control_spacing(), - - f:separator { fill_horizontal = 1 }, - - f:row { - f:static_text { title = "Album Description:", font = "", alignment = 'right', width = share 'label_width', }, - f:edit_field { - enabled = LrView.bind { - key = 'syncAlbumDescriptions', - object = publishSettings, - }, - value = bind 'albumDescription', - width_in_chars = 40, - font = "", - alignment = 'left', - height_in_lines = 4, - }, - }, - - f:row { - f:checkbox { - title = "Album is Private", - tooltip = "If checked, this album will be private on Piwigo", - value = bind 'albumPrivate', - } - } - } - } + local pwAlbumUI = UIHelpers.createPiwigoAlbumSettingsUI(f, share, bind, collectionSettings, publishSettings) - local kwFilterUI = f:group_box { - title = "Keyword Filtering (Overrides defaults set in Publish Settings)", - font = "", - size = 'regular', - fill_horizontal = 1, - bind_to_object = assert(collectionSettings), - f:column { - spacing = f:control_spacing(), - fill_horizontal = 1, - f:separator { fill_horizontal = 1 }, - f:static_text { - title = "One rule per line. Use * to match any characters, ? to match a single character.", - font = "", - }, - f:static_text { - title = "Leave empty to use global settings from Publish Settings.", - font = "", - }, - f:spacer { height = 2 }, - f:row { - fill_horizontal = 1, - spacing = f:control_spacing(), - f:column { - f:static_text { - title = "Exclusion Rules", - font = "", - }, - f:edit_field { - value = bind 'KwFilterExclude', - font = "", - alignment = 'left', - width_in_chars = 25, - height_in_lines = 6, - tooltip = "Photos with any keyword matching these rules will not be published. One rule per line.", - }, - }, - f:column { - f:static_text { - title = "Inclusion Rules", - font = "", - }, - f:edit_field { - value = bind 'KwFilterInclude', - font = "", - alignment = 'left', - width_in_chars = 25, - height_in_lines = 6, - tooltip = "Photos must have at least one keyword matching these rules to be published. Leave empty to allow all. One rule per line.", - }, - }, - }, - }, - } + local kwFilterUI = UIHelpers.createKeywordFilteringUI(f, bind, collectionSettings) local UI = f:column { spacing = f:control_spacing(), diff --git a/piwigoPublish.lrplugin/UIHelpers.lua b/piwigoPublish.lrplugin/UIHelpers.lua index 47c385b..d092764 100644 --- a/piwigoPublish.lrplugin/UIHelpers.lua +++ b/piwigoPublish.lrplugin/UIHelpers.lua @@ -64,4 +64,234 @@ function UIHelpers.createPluginHeader(f, share, iconPath, pluginVersion) } 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) + return f:group_box { + title = "Piwigo Album Settings", + font = "", + size = 'regular', + fill_horizontal = 1, + 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 = LrView.bind { + key = 'syncAlbumDescriptions', + object = publishSettings, + }, + + 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 = "", + } + } + } + } +end + +-- ************************************************* +-- Create Keyword Filtering inner elements (help texts + exclusion/inclusion fields) +-- Returns a flat list of UI elements to be inserted into a parent container +-- Options: +-- showOverrideHint (bool) : show "Leave empty to use global settings" text +-- widthInChars (number) : width of edit fields (default 30) +-- heightInLines (number) : height of edit fields (default 8) +-- 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 + + local exclusionColDef = { + f:static_text { + title = "Exclusion Rules", + font = "", + }, + f:edit_field { + value = bind 'KwFilterExclude', + font = "", + alignment = 'left', + width_in_chars = widthInChars, + height_in_lines = heightInLines, + fill_horizontal = fillColumns and 1 or nil, + 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", + font = "", + }, + f:edit_field { + value = bind 'KwFilterInclude', + font = "", + alignment = 'left', + width_in_chars = widthInChars, + height_in_lines = heightInLines, + fill_horizontal = fillColumns and 1 or nil, + 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 = { + 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 = "", + }, + } + + 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, + spacing = f:control_spacing(), + f:column(exclusionColDef), + f:column(inclusionColDef), + } + + return elements +end + +-- ************************************************* +-- Create Keyword Filtering UI section (standalone group_box) +-- Returns a group_box with exclusion and inclusion rules +-- Used in PublishTask for collection settings dialogs +-- ************************************************* +function UIHelpers.createKeywordFilteringUI(f, bind, collectionSettings) + local fields = UIHelpers.createKeywordFilteringFields(f, bind, { + showOverrideHint = true, + widthInChars = 35, + heightInLines = 6, + fillColumns = true, + }) + + local columnContents = { + spacing = f:control_spacing(), + fill_horizontal = 1, + f:separator { fill_horizontal = 1 }, + } + for _, elem in ipairs(fields) do + columnContents[#columnContents + 1] = elem + end + + return f:group_box { + title = "Keyword Filtering (Overrides defaults set in Publish Settings)", + font = "", + size = 'regular', + fill_horizontal = 1, + bind_to_object = assert(collectionSettings), + f:column(columnContents), + } +end + +-- ************************************************* +-- Create "Keyword Settings" group_box for PublishDialogSections +-- Combines checkboxes (Hierarchy/Synonyms) + filtering fields +-- Built dynamically to allow merging fixed elements with shared filtering fields +-- ************************************************* +function UIHelpers.createKeywordSettingsGroupBox(f, bind) + local filterFields = UIHelpers.createKeywordFilteringFields(f, bind, { + showOverrideHint = false, + widthInChars = 30, + heightInLines = 8, + fillColumns = false, + }) + + local groupBoxDef = { + title = "Keyword Settings", + font = "", + fill_horizontal = 1, + -- Checkboxes + f:spacer { height = 2 }, + f:row { + fill_horizontal = 1, + f:static_text { + title = "", + alignment = 'right', + width_in_chars = 7, + }, + f:checkbox { + font = "", + title = "Include Full Keyword Hierarchy", + tooltip = "If checked, all keywords in a keyword hierarchy will be sent to Piwigo", + value = bind 'KwFullHierarchy', + }, + }, + f:spacer { height = 2 }, + f:row { + fill_horizontal = 1, + f:static_text { + title = "", + alignment = 'right', + width_in_chars = 7, + }, + f:checkbox { + font = "", + title = "Include Keyword Synonyms", + tooltip = "If checked, keyword synonyms will be sent to Piwigo", + value = bind 'KwSynonyms', + }, + }, + f:spacer { height = 2 }, + f:separator { fill_horizontal = 1 }, + f:spacer { height = 2 }, + } + + -- Append filtering fields dynamically + for _, elem in ipairs(filterFields) do + groupBoxDef[#groupBoxDef + 1] = elem + end + + return f:group_box(groupBoxDef) +end + return UIHelpers \ No newline at end of file From 2f50bf32c931648f126d6033d1b9bff9151a34cb Mon Sep 17 00:00:00 2001 From: Gotcha26 Date: Mon, 9 Feb 2026 02:08:51 +0100 Subject: [PATCH 05/51] Various warnings --- piwigoPublish.lrplugin/Init.lua | 5 +- piwigoPublish.lrplugin/PWCollToSet.lua | 2 + piwigoPublish.lrplugin/PWExtraOptions.lua | 2 + piwigoPublish.lrplugin/PWImportService.lua | 2 + piwigoPublish.lrplugin/PWSendMetadata.lua | 7 ++ piwigoPublish.lrplugin/PWSetAlbumCover.lua | 9 +- piwigoPublish.lrplugin/PiwigoAPI.lua | 86 +++++++++++-------- .../PluginInfoDialogSections.lua | 2 + piwigoPublish.lrplugin/PublishTask.lua | 2 + piwigoPublish.lrplugin/Tagset.lua | 2 + piwigoPublish.lrplugin/UpdateChecker.lua | 2 + 11 files changed, 79 insertions(+), 42 deletions(-) diff --git a/piwigoPublish.lrplugin/Init.lua b/piwigoPublish.lrplugin/Init.lua index a555003..d71dd15 100644 --- a/piwigoPublish.lrplugin/Init.lua +++ b/piwigoPublish.lrplugin/Init.lua @@ -52,7 +52,10 @@ _G.PiwigoAPI = require "PiwigoAPI" _G.PWImportService = require "PWImportService" _G.PWStatusManager = require "PWStatusManager" --- 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') 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 3a64da7..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" -- ************************************************* diff --git a/piwigoPublish.lrplugin/PWImportService.lua b/piwigoPublish.lrplugin/PWImportService.lua index 87fb567..f277a63 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 diff --git a/piwigoPublish.lrplugin/PWSendMetadata.lua b/piwigoPublish.lrplugin/PWSendMetadata.lua index c0f48f2..95fc983 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 5fa0285..02369a5 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 05e9e4c..63a8f0d 100644 --- a/piwigoPublish.lrplugin/PiwigoAPI.lua +++ b/piwigoPublish.lrplugin/PiwigoAPI.lua @@ -22,6 +22,9 @@ 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 = {} -- ************************************************* @@ -119,7 +122,7 @@ local function httpPost(propertyTable, params, headers) -- successful connection to Piwigo -- Now check login result local rtnBody = JSON:decode(httpResponse) - if rtnBody.stat == "ok" then + if rtnBody and rtnBody.stat == "ok" then -- login ok - store session cookies local cookies = {} local SessionCookie = "" @@ -142,7 +145,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 @@ -245,31 +248,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 @@ -1608,6 +1619,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 @@ -1721,16 +1733,16 @@ function PiwigoAPI.pwCategoriesMove(propertyTable, info, thisCat, newCat, callSt parseResp = JSON:decode(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 @@ -1893,16 +1905,16 @@ function PiwigoAPI.pwCategoriesDelete(propertyTable, info, metaData, callStatus) parseResp = JSON:decode(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 @@ -1975,7 +1987,7 @@ function PiwigoAPI.pwCategoriesSetinfo(propertyTable, info, metaData) body = JSON:decode(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 @@ -1983,14 +1995,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 @@ -2250,7 +2262,7 @@ function PiwigoAPI.updateGallery(propertyTable, exportFilename, metaData) " to Piwigo - Invalid JSON response - " .. tostring(httpResponse)) 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 @@ -2485,7 +2497,7 @@ function PiwigoAPI.deletePhoto(propertyTable, pwCatID, pwImageID, callStatus) 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 @@ -2494,7 +2506,7 @@ function PiwigoAPI.deletePhoto(propertyTable, pwCatID, pwImageID, callStatus) 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(propertyTable)) @@ -2502,7 +2514,7 @@ function PiwigoAPI.deletePhoto(propertyTable, pwCatID, pwImageID, callStatus) 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 diff --git a/piwigoPublish.lrplugin/PluginInfoDialogSections.lua b/piwigoPublish.lrplugin/PluginInfoDialogSections.lua index e9ac4ea..9f01bdf 100644 --- a/piwigoPublish.lrplugin/PluginInfoDialogSections.lua +++ b/piwigoPublish.lrplugin/PluginInfoDialogSections.lua @@ -22,6 +22,8 @@ along with this program. If not, see . ]] +---@diagnostic disable: undefined-global + local LrHttp = import 'LrHttp' PluginInfoDialogSections = {} diff --git a/piwigoPublish.lrplugin/PublishTask.lua b/piwigoPublish.lrplugin/PublishTask.lua index 99f9046..edceb1e 100644 --- a/piwigoPublish.lrplugin/PublishTask.lua +++ b/piwigoPublish.lrplugin/PublishTask.lua @@ -22,6 +22,8 @@ along with this program. If not, see . ]] +---@diagnostic disable: undefined-global + PublishTask = {} -- ************************************************ 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/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' From 02d14bf7833bfb8cccc4f56ca3e49fc0185ed33a Mon Sep 17 00:00:00 2001 From: Gotcha26 Date: Mon, 9 Feb 2026 12:42:07 +0100 Subject: [PATCH 06/51] fix: #49 Back for developpement options. Better organisation layout too. --- .../PluginInfoDialogSections.lua | 214 ++++++++++++------ 1 file changed, 145 insertions(+), 69 deletions(-) diff --git a/piwigoPublish.lrplugin/PluginInfoDialogSections.lua b/piwigoPublish.lrplugin/PluginInfoDialogSections.lua index 9f01bdf..9f3d01e 100644 --- a/piwigoPublish.lrplugin/PluginInfoDialogSections.lua +++ b/piwigoPublish.lrplugin/PluginInfoDialogSections.lua @@ -37,7 +37,6 @@ local GITHUB_URL = "https://github.com/Piwigo/PiwigoPublish-lrc-plugin" -- HELPER FUNCTIONS -- ************************************************* -- Reset plugin preferences (optionally filtered by prefix) --- NOTE: Currently not exposed in the GUI but kept for potential future use local function resetPluginPrefs(prefix) log:info("resetPluginPrefs \n" .. utils.serialiseVar(prefs)) for k, p in prefs:pairs() do @@ -62,6 +61,9 @@ function PluginInfoDialogSections.startDialog(propertyTable) if prefs.debugEnabled == nil then prefs.debugEnabled = false end + if prefs.debugToFile == nil then + prefs.debugToFile = false + end -- Initialize update check preference if prefs.checkUpdatesOnStartup == nil then @@ -70,12 +72,17 @@ function PluginInfoDialogSections.startDialog(propertyTable) -- Apply debug settings if prefs.debugEnabled then - log:enable("logfile") + if prefs.debugToFile then + log:enable("logfile") + else + log:enable("print") + end else log:disable() end propertyTable.debugEnabled = prefs.debugEnabled + propertyTable.debugToFile = prefs.debugToFile propertyTable.checkUpdatesOnStartup = prefs.checkUpdatesOnStartup end @@ -85,6 +92,80 @@ function PluginInfoDialogSections.sectionsForBottomOfDialog(f, propertyTable) local share = LrView.share return { + -- =================================== + -- STATUS SECTION + -- =================================== + { + bind_to_object = propertyTable, + title = "Status", + + f:row { + f:column { + f:picture { + alignment = 'left', + value = iconPath, + }, + }, + f:column { + spacing = f:control_spacing(), + + f:static_text { + title = "Piwigo Publisher", + alignment = 'left', + font = "", + }, + + f:row { + f:static_text { + title = "Version:", + alignment = 'right', + width = share 'label_width', + }, + f:static_text { + title = pluginVersion, + alignment = 'left', + }, + }, + + f:row { + f:static_text { + title = "Update Status:", + alignment = 'right', + width = share 'label_width', + }, + f:static_text { + title = bind 'updateStatus', + alignment = 'left', + }, + }, + + f:row { + f:static_text { + title = "Plugin page:", + alignment = 'right', + width = share 'label_width', + }, + f:column { + f:static_text { + title = GITHUB_URL, + alignment = 'left', + text_color = LrColor("blue"), + mouse_down = function() + LrHttp.openUrlInBrowser(GITHUB_URL) + end, + }, + f:push_button { + title = "Visit...", + action = function() + LrHttp.openUrlInBrowser(GITHUB_URL) + end, + }, + }, + }, + }, + }, + }, + -- =================================== -- SELF UPDATE SECTION -- =================================== @@ -118,7 +199,7 @@ function PluginInfoDialogSections.sectionsForBottomOfDialog(f, propertyTable) f:row { f:static_text { - title = "If you have a problem with Piwigo Publisher then I'll probably ask you to activate the debug logging. This will save all sorts of useful information into a file.", + title = "If you have a problem with Piwigo Publisher then I'll probably ask you to activate the debug logging. This will save all sorts of useful information to the Lightroom console.", width_in_chars = 60, height_in_lines = 2, alignment = 'left', @@ -141,7 +222,7 @@ function PluginInfoDialogSections.sectionsForBottomOfDialog(f, propertyTable) f:radio_button { value = bind 'debugEnabled', checked_value = true, - title = "Log debug information to a file (PiwigoPublishPlugin.log) in your Lightroom logs folder", + title = "Log debug information to the Lightroom console", }, }, @@ -156,78 +237,67 @@ function PluginInfoDialogSections.sectionsForBottomOfDialog(f, propertyTable) end, }, }, - }, - -- =================================== - -- STATUS SECTION - -- =================================== - { - bind_to_object = propertyTable, - title = "Status", + f:separator { fill_horizontal = 1 }, f:row { - f:column { - f:picture { - alignment = 'left', - value = iconPath, - }, + f:static_text { + title = "Unsafe area — Development only", + font = "", + alignment = 'left', + text_color = LrColor("red"), }, - f:column { - spacing = f:control_spacing(), + }, - f:static_text { - title = "Piwigo Publisher", - alignment = 'left', - font = "", - }, + f:row { + f:static_text { + title = "These options are intended for plugin development and troubleshooting only.", + width_in_chars = 60, + alignment = 'left', + text_color = LrColor(0.4, 0.4, 0.4), + }, + }, - f:row { - f:static_text { - title = "Version:", - alignment = 'right', - width = share 'label_width', - }, - f:static_text { - title = pluginVersion, - alignment = 'left', - }, - }, + f:row { + f:checkbox { + value = bind 'debugToFile', + enabled = bind 'debugEnabled', + }, + f:static_text { + title = "Log to file instead of console", + alignment = 'left', + }, + }, - f:row { - f:static_text { - title = "Update Status:", - alignment = 'right', - width = share 'label_width', - }, - f:static_text { - title = bind 'updateStatus', - alignment = 'left', - }, - }, + f:row { + f:static_text { + title = utils.getLogfilePath(), + }, + }, - f:row { - f:static_text { - title = "Plugin page:", - alignment = 'right', - width = share 'label_width', - }, - f:column { - f:static_text { - title = GITHUB_URL, - alignment = 'left', - text_color = LrColor("blue"), - mouse_down = function() - LrHttp.openUrlInBrowser(GITHUB_URL) - end, - }, - f:push_button { - title = "Visit...", - action = function() - LrHttp.openUrlInBrowser(GITHUB_URL) - end, - }, - }, - }, + f:row { + spacing = f:control_spacing(), + + f:push_button { + title = "Reset Plugin Preferences…", + + action = function() + local result = LrDialogs.confirm( + "Reset Plugin Preferences", + "This will delete all saved settings for this plugin.\n\nThis cannot be undone.", + "Reset", + "Cancel" + ) + + if result == "ok" then + resetPluginPrefs() + LrDialogs.message( + "Preferences Reset", + "Plugin preferences have been cleared.", + "info" + ) + end + end, }, }, }, @@ -309,17 +379,23 @@ function PluginInfoDialogSections.sectionsForBottomOfDialog(f, propertyTable) }, }, }, + } end -- ************************************************* function PluginInfoDialogSections.endDialog(propertyTable) prefs.debugEnabled = propertyTable.debugEnabled + prefs.debugToFile = propertyTable.debugToFile prefs.checkUpdatesOnStartup = propertyTable.checkUpdatesOnStartup -- Apply debug settings if prefs.debugEnabled then - log:enable("logfile") + if prefs.debugToFile then + log:enable("logfile") + else + log:enable("print") + end else log:disable() end From 78a239530561e54802a23ee763745d2395bd4897 Mon Sep 17 00:00:00 2001 From: Gotcha26 Date: Mon, 9 Feb 2026 13:55:54 +0100 Subject: [PATCH 07/51] fix: #42 Botton est now representative, status is shorter and the Piwigo version working. --- piwigoPublish.lrplugin/PiwigoAPI.lua | 7 +++--- .../PublishDialogSections.lua | 25 ++++++++++++++++--- 2 files changed, 25 insertions(+), 7 deletions(-) diff --git a/piwigoPublish.lrplugin/PiwigoAPI.lua b/piwigoPublish.lrplugin/PiwigoAPI.lua index 63a8f0d..946270f 100644 --- a/piwigoPublish.lrplugin/PiwigoAPI.lua +++ b/piwigoPublish.lrplugin/PiwigoAPI.lua @@ -222,11 +222,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 diff --git a/piwigoPublish.lrplugin/PublishDialogSections.lua b/piwigoPublish.lrplugin/PublishDialogSections.lua index b5a6a8a..4e3fb77 100644 --- a/piwigoPublish.lrplugin/PublishDialogSections.lua +++ b/piwigoPublish.lrplugin/PublishDialogSections.lua @@ -106,13 +106,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, From 3a7993f66924910bb301d8b61a585a671b6f4896 Mon Sep 17 00:00:00 2001 From: Gotcha26 Date: Mon, 9 Feb 2026 14:48:15 +0100 Subject: [PATCH 08/51] fix: #46 - Manual sort order --- piwigoPublish.lrplugin/PiwigoAPI.lua | 66 +++++++++ .../PublishDialogSections.lua | 16 +++ .../PublishServiceProvider.lua | 3 +- piwigoPublish.lrplugin/PublishTask.lua | 134 +++++++++++++----- 4 files changed, 179 insertions(+), 40 deletions(-) diff --git a/piwigoPublish.lrplugin/PiwigoAPI.lua b/piwigoPublish.lrplugin/PiwigoAPI.lua index 946270f..17b6a08 100644 --- a/piwigoPublish.lrplugin/PiwigoAPI.lua +++ b/piwigoPublish.lrplugin/PiwigoAPI.lua @@ -3023,6 +3023,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 { { diff --git a/piwigoPublish.lrplugin/PublishDialogSections.lua b/piwigoPublish.lrplugin/PublishDialogSections.lua index 4e3fb77..402bacf 100644 --- a/piwigoPublish.lrplugin/PublishDialogSections.lua +++ b/piwigoPublish.lrplugin/PublishDialogSections.lua @@ -420,6 +420,22 @@ local function prefsDialog(f, propertyTable) }, 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 { diff --git a/piwigoPublish.lrplugin/PublishServiceProvider.lua b/piwigoPublish.lrplugin/PublishServiceProvider.lua index 5055ed7..1e82e95 100644 --- a/piwigoPublish.lrplugin/PublishServiceProvider.lua +++ b/piwigoPublish.lrplugin/PublishServiceProvider.lua @@ -40,7 +40,7 @@ return { allowFileFormats = { "JPEG", "PNG" }, allowColorSpaces = nil, canExportVideo = false, - supportsCustomSortOrder = false, + supportsCustomSortOrder = true, hidePrintResolution = true, supportsIncrementalPublish = 'only', -- plugin only visible in publish services, not export @@ -55,6 +55,7 @@ return { { key = "mdTitle", default = "{{title}}" }, { key = "mdDescription", default = "{{caption}}" }, { key = "syncAlbumDescriptions", default = false }, + { key = "syncPhotoSortOrder", default = false }, { key = "syncCommentsPublish", default = true }, { key = "syncCommentsPubOnly", default = false }, { key = "KwFilterInclude", default = '' }, diff --git a/piwigoPublish.lrplugin/PublishTask.lua b/piwigoPublish.lrplugin/PublishTask.lua index edceb1e..5d57fdf 100644 --- a/piwigoPublish.lrplugin/PublishTask.lua +++ b/piwigoPublish.lrplugin/PublishTask.lua @@ -683,61 +683,89 @@ 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 - end + local finalSequence = remoteIdSequence + local isSmartCollection = publishedCollection:isSmartCollection() - -- 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 - end + -- 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 - -- 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 + local publishedPhotos = publishedCollection:getPublishedPhotos() + local remoteIdToPhoto = {} + for _, pubPhoto in ipairs(publishedPhotos) do + local remoteId = pubPhoto:getRemoteId() + if remoteId then + remoteIdToPhoto[remoteId] = pubPhoto + end 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) + 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 - -- If the photo is no longer in currentPhotoIds, it will be marked for deletion because - -- its remoteId will not be in validSequence. end + + log:info("PublishTask.imposeSortOrderOnPublishedCollection - " .. + #remoteIdSequence .. " published, " .. + #validSequence .. " still match criteria, " .. + (#remoteIdSequence - #validSequence) .. " to delete") + + finalSequence = validSequence end - log:info("PublishTask.imposeSortOrderOnPublishedCollection - " .. - #remoteIdSequence .. " published, " .. - #validSequence .. " still match criteria, " .. - (#remoteIdSequence - #validSequence) .. " to delete") + -- 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 + + 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 + else + log:info("PublishTask.imposeSortOrderOnPublishedCollection - no remoteId for collection, skipping sort sync") + end + end - return validSequence + if isSmartCollection then + return finalSequence + end + return nil end -- ************************************************ @@ -846,6 +874,9 @@ function PublishTask.viewForCollectionSettings(f, publishSettings, info) if collectionSettings.KwFilterExclude == nil then collectionSettings.KwFilterExclude = "" end + if collectionSettings.syncSortOrderOverride == nil then + collectionSettings.syncSortOrderOverride = "default" + end -- build UI local reSizeOptions = { { title = "Long Edge", value = "Long Edge" }, @@ -942,9 +973,34 @@ function PublishTask.viewForCollectionSettings(f, publishSettings, info) local kwFilterUI = UIHelpers.createKeywordFilteringUI(f, bind, collectionSettings) + local sortOrderUI = f:group_box { + title = "Sort Order", + font = "", + size = 'regular', + fill_horizontal = 1, + bind_to_object = assert(collectionSettings), + f:row { + fill_horizontal = 1, + f:static_text { + title = "Sync sort order to Piwigo:", + font = "", + alignment = 'right', + }, + f:popup_menu { + value = bind 'syncSortOrderOverride', + items = { + { title = "Use global setting", value = "default" }, + { title = "Always sync", value = "always" }, + { title = "Never sync", value = "never" }, + }, + }, + }, + } + local UI = f:column { spacing = f:control_spacing(), pwAlbumUI, + sortOrderUI, kwFilterUI, --pubSettingsUI, } From 8bfba5fb5d3535851e8657c9da3a3d1b1a062b0a Mon Sep 17 00:00:00 2001 From: Gotcha26 Date: Mon, 9 Feb 2026 14:59:07 +0100 Subject: [PATCH 09/51] fix: #50 - Refactor `viewForCollectionSettings --- piwigoPublish.lrplugin/PublishTask.lua | 236 ++++++++----------------- 1 file changed, 69 insertions(+), 167 deletions(-) diff --git a/piwigoPublish.lrplugin/PublishTask.lua b/piwigoPublish.lrplugin/PublishTask.lua index 5d57fdf..a21af04 100644 --- a/piwigoPublish.lrplugin/PublishTask.lua +++ b/piwigoPublish.lrplugin/PublishTask.lua @@ -797,6 +797,69 @@ local function valueEqual(a, b) return a == b end +-- ************************************************ +-- ************************************************ +-- Shared helpers for viewForCollectionSettings / viewForCollectionSetSettings +-- ************************************************ +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, + KwFullHierarchy = true, + KwSynonyms = true, + KwFilterInclude = "", + KwFilterExclude = "", + syncSortOrderOverride = "default", + } + for key, defaultVal in pairs(defaults) do + if collectionSettings[key] == nil then + collectionSettings[key] = defaultVal + end + 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) + local sortOrderUI = f:group_box { + title = "Sort Order", + font = "", + size = 'regular', + fill_horizontal = 1, + bind_to_object = assert(collectionSettings), + f:row { + fill_horizontal = 1, + f:static_text { + title = "Sync sort order to Piwigo:", + font = "", + alignment = 'right', + }, + f:popup_menu { + value = bind 'syncSortOrderOverride', + items = { + { title = "Use global setting", value = "default" }, + { title = "Always sync", value = "always" }, + { title = "Never sync", value = "never" }, + }, + }, + }, + } + return pwAlbumUI, sortOrderUI, kwFilterUI +end + -- ************************************************ function PublishTask.viewForCollectionSettings(f, publishSettings, info) log:info("PublishTask.viewForCollectionSettings") @@ -814,69 +877,9 @@ function PublishTask.viewForCollectionSettings(f, publishSettings, info) local bind = LrView.bind local share = LrView.share local collectionSettings = assert(info.collectionSettings) - -- piwigo album settings - if collectionSettings.albumDescription == nil then - collectionSettings.albumDescription = "" - end - if collectionSettings.albumPrivate == nil then - collectionSettings.albumPrivate = false - end - -- customisation of image export settings - if collectionSettings.enableCustom == nil then - collectionSettings.enableCustom = false - end - if collectionSettings.reSize == nil then - collectionSettings.reSize = false - end - if collectionSettings.reSizeParam == nil then - collectionSettings.reSizeParam = "Long Edge" - end - if collectionSettings.reSizeNoEnlarge == nil then - collectionSettings.reSizeNoEnlarge = true - end - if collectionSettings.reSizeLongEdge == nil then - collectionSettings.reSizeLongEdge = 1024 - end - if collectionSettings.reSizeShortEdge == nil then - collectionSettings.reSizeShortEdge = 1024 - end - if collectionSettings.reSizeW == nil then - collectionSettings.reSizeW = 1024 - end - if collectionSettings.reSizeH == nil then - collectionSettings.reSizeH = 1024 - end - if collectionSettings.reSizeMP == nil then - collectionSettings.reSizeMP = 5 - end - if collectionSettings.reSizePC == nil then - collectionSettings.reSizePC = 50 - end - if collectionSettings.metaData == nil then - collectionSettings.metaData = "All" - end - if collectionSettings.metaDataNoPerson == nil then - collectionSettings.metaDataNoPerson = true - end - if collectionSettings.metaDataNoLocation == nil then - collectionSettings.metaDataNoLocation = false - end - if collectionSettings.KwFullHierarchy == nil then - collectionSettings.KwFullHierarchy = true - end - if collectionSettings.KwSynonyms == nil then - collectionSettings.KwSynonyms = true - end - if collectionSettings.KwFilterInclude == nil then - collectionSettings.KwFilterInclude = "" - end - if collectionSettings.KwFilterExclude == nil then - collectionSettings.KwFilterExclude = "" - end - if collectionSettings.syncSortOrderOverride == nil then - collectionSettings.syncSortOrderOverride = "default" - end + initCollectionSettingsDefaults(collectionSettings) + -- build UI local reSizeOptions = { { title = "Long Edge", value = "Long Edge" }, @@ -893,7 +896,7 @@ function PublishTask.viewForCollectionSettings(f, publishSettings, info) { title = "All Except Camera & Camera Raw Info", value = "All Except Camera & Camera Raw Info" }, } - local pwAlbumUI = UIHelpers.createPiwigoAlbumSettingsUI(f, share, bind, collectionSettings, publishSettings) + local pwAlbumUI, sortOrderUI, kwFilterUI = buildCommonCollectionUI(f, bind, share, collectionSettings, publishSettings) local pubSettingsUI = f:group_box { title = "Custom Publish Settings (Overrides defaults set in Publish Settings)", @@ -971,32 +974,6 @@ function PublishTask.viewForCollectionSettings(f, publishSettings, info) }, } - local kwFilterUI = UIHelpers.createKeywordFilteringUI(f, bind, collectionSettings) - - local sortOrderUI = f:group_box { - title = "Sort Order", - font = "", - size = 'regular', - fill_horizontal = 1, - bind_to_object = assert(collectionSettings), - f:row { - fill_horizontal = 1, - f:static_text { - title = "Sync sort order to Piwigo:", - font = "", - alignment = 'right', - }, - f:popup_menu { - value = bind 'syncSortOrderOverride', - items = { - { title = "Use global setting", value = "default" }, - { title = "Always sync", value = "always" }, - { title = "Never sync", value = "never" }, - }, - }, - }, - } - local UI = f:column { spacing = f:control_spacing(), pwAlbumUI, @@ -1107,91 +1084,16 @@ function PublishTask.viewForCollectionSetSettings(f, publishSettings, info) local bind = LrView.bind local share = LrView.share local collectionSettings = assert(info.collectionSettings) - -- piwigo album settings - if collectionSettings.albumDescription == nil then - collectionSettings.albumDescription = "" - end - if collectionSettings.albumPrivate == nil then - collectionSettings.albumPrivate = false - end - - -- customisation of image export settings - if collectionSettings.enableCustom == nil then - collectionSettings.enableCustom = false - end - if collectionSettings.reSize == nil then - collectionSettings.reSize = false - end - if collectionSettings.reSizeParam == nil then - collectionSettings.reSizeParam = "Long Edge" - end - if collectionSettings.reSizeNoEnlarge == nil then - collectionSettings.reSizeNoEnlarge = true - end - if collectionSettings.reSizeLongEdge == nil then - collectionSettings.reSizeLongEdge = 1024 - end - if collectionSettings.reSizeShortEdge == nil then - collectionSettings.reSizeShortEdge = 1024 - end - if collectionSettings.reSizeW == nil then - collectionSettings.reSizeW = 1024 - end - if collectionSettings.reSizeH == nil then - collectionSettings.reSizeH = 1024 - end - if collectionSettings.reSizeMP == nil then - collectionSettings.reSizeMP = 5 - end - if collectionSettings.reSizePC == nil then - collectionSettings.reSizePC = 50 - end - if collectionSettings.metaData == nil then - collectionSettings.metaData = "All" - end - if collectionSettings.metaDataNoPerson == nil then - collectionSettings.metaDataNoPerson = true - end - if collectionSettings.metaDataNoLocation == nil then - collectionSettings.metaDataNoLocation = false - end - if collectionSettings.KwFullHierarchy == nil then - collectionSettings.KwFullHierarchy = true - end - if collectionSettings.KwSynonyms == nil then - collectionSettings.KwSynonyms = true - end - if collectionSettings.KwFilterInclude == nil then - collectionSettings.KwFilterInclude = "" - end - if collectionSettings.KwFilterExclude == nil then - collectionSettings.KwFilterExclude = "" - end - -- 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 = UIHelpers.createPiwigoAlbumSettingsUI(f, share, bind, collectionSettings, publishSettings) + initCollectionSettingsDefaults(collectionSettings) - local kwFilterUI = UIHelpers.createKeywordFilteringUI(f, bind, collectionSettings) + local pwAlbumUI, sortOrderUI, kwFilterUI = buildCommonCollectionUI(f, bind, share, collectionSettings, publishSettings) local UI = f:column { spacing = f:control_spacing(), pwAlbumUI, + sortOrderUI, kwFilterUI, - --pubSettingsUI, } return UI end From 06f16500a8f6a7a87ad9ada0d99eef53567d8ab1 Mon Sep 17 00:00:00 2001 From: Gotcha26 Date: Fri, 13 Feb 2026 23:54:14 +0100 Subject: [PATCH 10/51] feat: #51 - Album summary --- .../PublishDialogSections.lua | 143 ++++++++++++++++++ piwigoPublish.lrplugin/utils.lua | 124 +++++++++++++++ 2 files changed, 267 insertions(+) diff --git a/piwigoPublish.lrplugin/PublishDialogSections.lua b/piwigoPublish.lrplugin/PublishDialogSections.lua index 402bacf..059832a 100644 --- a/piwigoPublish.lrplugin/PublishDialogSections.lua +++ b/piwigoPublish.lrplugin/PublishDialogSections.lua @@ -349,6 +349,149 @@ local function prefsDialog(f, propertyTable) }, f:spacer { height = 1 }, + f:row { + f:push_button { + title = "Album Summary\n ", + font = "", + width = share 'buttonwidth', + enabled = bind('Connected', 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 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 + + -- Build LrView dialog + local dlgF = LrView.osFactory() + + -- 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 + + 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 }) + 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 >", + }) + end) + end, + }, + f:static_text { + title = "Show a summary of all albums with photo counts\n(published, modified, new to publish)", + font = "", + alignment = 'left', + tooltip = "Display a summary dialog listing all albums and their photo status counts" + }, + }, + f:spacer { height = 1 }, + }, f:group_box { diff --git a/piwigoPublish.lrplugin/utils.lua b/piwigoPublish.lrplugin/utils.lua index 4517874..35d5206 100644 --- a/piwigoPublish.lrplugin/utils.lua +++ b/piwigoPublish.lrplugin/utils.lua @@ -1349,4 +1349,128 @@ function utils.findExistingPwImageId(publishService, lrPhoto) return foundRemoteId end +-- ************************************************* +function utils.buildAlbumSummary(publishService) + -- Recursively build a tree of collection sets and collections with photo counts. + -- Returns a flat list of display nodes, each being either a "set" (parent) or "collection" (leaf). + -- Set nodes include aggregated sub-totals. Collection nodes include individual counts. + -- Returns: { nodes = { ... }, totals = {published, modified, new} } + -- node fields: type ("set"|"collection"), name, path, depth, published, modified, new + + local nodes = {} + local totals = { published = 0, modified = 0, new = 0 } + + local function countCollection(collection) + local published, modified, new = 0, 0, 0 + local pubPhotos = collection:getPublishedPhotos() + for _, pubPhoto in ipairs(pubPhotos) do + local remoteId = pubPhoto:getRemoteId() + if not remoteId or remoteId == "" then + new = new + 1 + elseif pubPhoto:getEditedFlag() then + modified = modified + 1 + else + published = published + 1 + end + end + return published, modified, new + end + + local function collectFromSet(collectionSet, path, depth) + local name = collectionSet:getName() + local fullPath = path ~= "" and (path .. " / " .. name) or name + local setPub, setMod, setNew = 0, 0, 0 + local childNodes = {} + + -- Child collections + local childColls = collectionSet:getChildCollections() + if childColls then + for _, coll in ipairs(childColls) do + local p, m, n = countCollection(coll) + if p > 0 or m > 0 or n > 0 then + table.insert(childNodes, { + type = "collection", + name = coll:getName(), + path = fullPath .. " / " .. coll:getName(), + depth = depth + 1, + published = p, modified = m, new = n, + }) + setPub = setPub + p + setMod = setMod + m + setNew = setNew + n + end + end + end + + -- Child sets (recursive) + local childSets = collectionSet:getChildCollectionSets() + if childSets then + for _, childSet in ipairs(childSets) do + local subNodes, sp, sm, sn = collectFromSet(childSet, fullPath, depth + 1) + for _, node in ipairs(subNodes) do + table.insert(childNodes, node) + end + setPub = setPub + sp + setMod = setMod + sm + setNew = setNew + sn + end + end + + -- Only emit if this set has content + local result = {} + if setPub > 0 or setMod > 0 or setNew > 0 then + -- Insert set header with sub-totals + table.insert(result, { + type = "set", + name = name, + path = fullPath, + depth = depth, + published = setPub, modified = setMod, new = setNew, + }) + -- Then all children + for _, node in ipairs(childNodes) do + table.insert(result, node) + end + end + + return result, setPub, setMod, setNew + end + + -- Root-level collections (no parent set) + local childColls = publishService:getChildCollections() + if childColls then + for _, coll in ipairs(childColls) do + local p, m, n = countCollection(coll) + if p > 0 or m > 0 or n > 0 then + table.insert(nodes, { + type = "collection", + name = coll:getName(), + path = coll:getName(), + depth = 0, + published = p, modified = m, new = n, + }) + totals.published = totals.published + p + totals.modified = totals.modified + m + totals.new = totals.new + n + end + end + end + + -- Root-level sets + local childSets = publishService:getChildCollectionSets() + if childSets then + for _, set in ipairs(childSets) do + local subNodes, sp, sm, sn = collectFromSet(set, "", 0) + for _, node in ipairs(subNodes) do + table.insert(nodes, node) + end + totals.published = totals.published + sp + totals.modified = totals.modified + sm + totals.new = totals.new + sn + end + end + + return { nodes = nodes, totals = totals } +end + return utils From df9fe2101131d81f3a7a4018589d87dfa848c79e Mon Sep 17 00:00:00 2001 From: Gotcha26 Date: Sat, 14 Feb 2026 13:11:19 +0100 Subject: [PATCH 11/51] fix: #52 - New "private" album wasn't private. --- piwigoPublish.lrplugin/PiwigoAPI.lua | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/piwigoPublish.lrplugin/PiwigoAPI.lua b/piwigoPublish.lrplugin/PiwigoAPI.lua index 17b6a08..00749a0 100644 --- a/piwigoPublish.lrplugin/PiwigoAPI.lua +++ b/piwigoPublish.lrplugin/PiwigoAPI.lua @@ -1793,12 +1793,13 @@ function PiwigoAPI.pwCategoriesAdd(propertyTable, info, metaData, callStatus) name = "comment", value = description }) - table.insert(Params, { - name = "status", - value = albumstatus - }) end + table.insert(Params, { + name = "status", + value = albumstatus + }) + if metaData.parentCat ~= "" then table.insert(Params, { name = "parent", From da81c4faab6bb56bf6fceb6a6144faf1874f674a Mon Sep 17 00:00:00 2001 From: Gotcha26 Date: Sat, 14 Feb 2026 13:27:18 +0100 Subject: [PATCH 12/51] fix: #52 - Update the private status everytime to keep correct attribut --- piwigoPublish.lrplugin/PublishTask.lua | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/piwigoPublish.lrplugin/PublishTask.lua b/piwigoPublish.lrplugin/PublishTask.lua index a21af04..bb6979e 100644 --- a/piwigoPublish.lrplugin/PublishTask.lua +++ b/piwigoPublish.lrplugin/PublishTask.lua @@ -114,12 +114,30 @@ function PublishTask.processRenderedPhotos(functionContext, exportContext) return nil end end + if not utils.nilOrEmpty(checkCats) and albumId then + -- album exists on Piwigo — sync status (private/public) from collection settings + 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 From 403a898ee815c39cf569dd45396f8cdb7830b1f0 Mon Sep 17 00:00:00 2001 From: Gotcha26 Date: Sun, 15 Feb 2026 01:23:50 +0100 Subject: [PATCH 13/51] fix: #53 - Keeping information up to date --- piwigoPublish.lrplugin/PWImportService.lua | 28 +-- piwigoPublish.lrplugin/PiwigoAPI.lua | 44 ++--- .../PublishDialogSections.lua | 34 +++- .../PublishServiceProvider.lua | 3 +- piwigoPublish.lrplugin/PublishTask.lua | 168 +++++++++++++++++- piwigoPublish.lrplugin/UIHelpers.lua | 6 +- 6 files changed, 214 insertions(+), 69 deletions(-) diff --git a/piwigoPublish.lrplugin/PWImportService.lua b/piwigoPublish.lrplugin/PWImportService.lua index f277a63..120fd53 100644 --- a/piwigoPublish.lrplugin/PWImportService.lua +++ b/piwigoPublish.lrplugin/PWImportService.lua @@ -396,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 @@ -411,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 @@ -478,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 @@ -492,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/PiwigoAPI.lua b/piwigoPublish.lrplugin/PiwigoAPI.lua index 00749a0..2fd46c4 100644 --- a/piwigoPublish.lrplugin/PiwigoAPI.lua +++ b/piwigoPublish.lrplugin/PiwigoAPI.lua @@ -785,11 +785,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 @@ -819,11 +815,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 @@ -848,11 +840,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 @@ -866,11 +854,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 @@ -1788,12 +1772,10 @@ 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", @@ -1963,12 +1945,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 diff --git a/piwigoPublish.lrplugin/PublishDialogSections.lua b/piwigoPublish.lrplugin/PublishDialogSections.lua index 059832a..20ede8f 100644 --- a/piwigoPublish.lrplugin/PublishDialogSections.lua +++ b/piwigoPublish.lrplugin/PublishDialogSections.lua @@ -550,15 +550,37 @@ local function prefsDialog(f, propertyTable) f:row { fill_horizontal = 1, f:static_text { - title = "", + title = "Album description :", + font = "", alignment = 'right', - width_in_chars = 7, + width_in_chars = 18, }, - f:checkbox { - title = "Synchronise Album Descriptions", + 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 = "", - tooltip = "If checked, Album descriptions will be maintainable in Lightroom and sent to Piwigo", - value = bind 'syncAlbumDescriptions', + 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 }, diff --git a/piwigoPublish.lrplugin/PublishServiceProvider.lua b/piwigoPublish.lrplugin/PublishServiceProvider.lua index 1e82e95..f5f1b68 100644 --- a/piwigoPublish.lrplugin/PublishServiceProvider.lua +++ b/piwigoPublish.lrplugin/PublishServiceProvider.lua @@ -54,7 +54,8 @@ return { { key = "KwSynonyms", default = true }, { key = "mdTitle", default = "{{title}}" }, { key = "mdDescription", default = "{{caption}}" }, - { key = "syncAlbumDescriptions", default = false }, + { key = "albumDescSyncMode", default = "ask" }, + { key = "albumStatusSyncMode", default = "ask" }, { key = "syncPhotoSortOrder", default = false }, { key = "syncCommentsPublish", default = true }, { key = "syncCommentsPubOnly", default = false }, diff --git a/piwigoPublish.lrplugin/PublishTask.lua b/piwigoPublish.lrplugin/PublishTask.lua index bb6979e..d2257ba 100644 --- a/piwigoPublish.lrplugin/PublishTask.lua +++ b/piwigoPublish.lrplugin/PublishTask.lua @@ -26,6 +26,150 @@ 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 @@ -115,7 +259,12 @@ function PublishTask.processRenderedPhotos(functionContext, exportContext) end end if not utils.nilOrEmpty(checkCats) and albumId then - -- album exists on Piwigo — sync status (private/public) from collection settings + -- 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 @@ -1064,6 +1213,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 @@ -1184,6 +1338,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 @@ -1292,6 +1451,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 diff --git a/piwigoPublish.lrplugin/UIHelpers.lua b/piwigoPublish.lrplugin/UIHelpers.lua index d092764..218dac8 100644 --- a/piwigoPublish.lrplugin/UIHelpers.lua +++ b/piwigoPublish.lrplugin/UIHelpers.lua @@ -84,11 +84,7 @@ function UIHelpers.createPiwigoAlbumSettingsUI(f, share, bind, collectionSetting fill_horizontal = 1, f:static_text { title = "Album Description:", font = "", alignment = 'right', width = share 'label_width', }, f:edit_field { - enabled = LrView.bind { - key = 'syncAlbumDescriptions', - object = publishSettings, - }, - + enabled = true, value = bind 'albumDescription', fill_horizontal = 1, width_in_chars = 70, From 070d56cb0843407847effd4014c663195d4e8c69 Mon Sep 17 00:00:00 2001 From: Gotcha26 Date: Sun, 15 Feb 2026 23:17:34 +0100 Subject: [PATCH 14/51] fix: #54 - GPS metadata aren't updated --- piwigoPublish.lrplugin/PiwigoAPI.lua | 12 ++++++++++++ piwigoPublish.lrplugin/utils.lua | 7 +++++++ 2 files changed, 19 insertions(+) diff --git a/piwigoPublish.lrplugin/PiwigoAPI.lua b/piwigoPublish.lrplugin/PiwigoAPI.lua index 2fd46c4..0710a62 100644 --- a/piwigoPublish.lrplugin/PiwigoAPI.lua +++ b/piwigoPublish.lrplugin/PiwigoAPI.lua @@ -2394,6 +2394,18 @@ function PiwigoAPI.updateMetadata(propertyTable, lrPhoto, metaData) }) end + -- GPS coordinates + if metaData.latitude and metaData.longitude then + table.insert(params, { + name = "latitude", + value = tostring(metaData.latitude) + }) + table.insert(params, { + name = "longitude", + value = tostring(metaData.longitude) + }) + end + -- keywords if metaData.tagString and metaData.tagString ~= "" then -- convert tagString to list of tagIDS diff --git a/piwigoPublish.lrplugin/utils.lua b/piwigoPublish.lrplugin/utils.lua index 35d5206..15e4bfa 100644 --- a/piwigoPublish.lrplugin/utils.lua +++ b/piwigoPublish.lrplugin/utils.lua @@ -620,6 +620,13 @@ function utils.getPhotoMetadata(publishSettings, lrPhoto) metaData.tagString = utils.BuildTagString(publishSettings, lrPhoto) + -- GPS coordinates + local gps = lrPhoto:getRawMetadata("gps") + if gps then + metaData.latitude = gps.latitude + metaData.longitude = gps.longitude + end + return metaData end From 90d83a4764e47d5212cb92f1ba604c5afe9c3c7b Mon Sep 17 00:00:00 2001 From: Gotcha26 Date: Sun, 15 Feb 2026 23:21:48 +0100 Subject: [PATCH 15/51] Small technical update --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index b8d062a..c0f111e 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 From 5e575102a383f7bc0bbbfa1cd27cc2d56e8bc7bb Mon Sep 17 00:00:00 2001 From: Gotcha26 Date: Mon, 16 Feb 2026 22:41:35 +0100 Subject: [PATCH 16/51] feat: #55 - Allow video publishing --- lightroom-companion/admin.php | 215 ++++++++++++ lightroom-companion/admin.tpl | 293 ++++++++++++++++ lightroom-companion/main.inc.php | 318 ++++++++++++++++++ piwigoPublish.lrplugin/PiwigoAPI.lua | 201 ++++++++++- .../PublishDialogSections.lua | 306 +++++++++++++++++ .../PublishServiceProvider.lua | 6 +- piwigoPublish.lrplugin/PublishTask.lua | 159 ++++++++- piwigoPublish.lrplugin/utils.lua | 16 + 8 files changed, 1485 insertions(+), 29 deletions(-) create mode 100644 lightroom-companion/admin.php create mode 100644 lightroom-companion/admin.tpl create mode 100644 lightroom-companion/main.inc.php diff --git a/lightroom-companion/admin.php b/lightroom-companion/admin.php new file mode 100644 index 0000000..9d7a4c9 --- /dev/null +++ b/lightroom-companion/admin.php @@ -0,0 +1,215 @@ +add('video', 'Video', get_admin_plugin_menu_link(dirname(__FILE__).'/admin.php') . '&tab=video'); +$tabsheet->add('server', 'Server', get_admin_plugin_menu_link(dirname(__FILE__).'/admin.php') . '&tab=server'); +$tabsheet->select($page['tab']); +$tabsheet->assign(); + +// ========================================================================= +// Handle POST action: Enable Video Support +// ========================================================================= +$action_status = null; +$action_message = null; + +if (isset($_POST['action']) && $_POST['action'] === 'enable_video_support') +{ + check_pwg_token(); + $dummy_service = null; + $result = companion_enable_video_support(array(), $dummy_service); + $action_status = $result['status']; + $action_message = $result['message']; +} + +// ========================================================================= +// Gather server information +// ========================================================================= + +// PHP +$disabled_functions = array_map('trim', explode(',', ini_get('disable_functions'))); +$exec_available = function_exists('exec') && !in_array('exec', $disabled_functions); + +$php = array( + 'version' => PHP_VERSION, + 'memory_limit' => ini_get('memory_limit'), + 'upload_max_filesize' => ini_get('upload_max_filesize'), + 'post_max_size' => ini_get('post_max_size'), + 'max_execution_time' => ini_get('max_execution_time'), + 'exec_available' => $exec_available, +); + +// Graphics +$gfx_gd = false; +$gfx_imagick = false; +if (function_exists('gd_info')) +{ + $gd = gd_info(); + $gfx_gd = isset($gd['GD Version']) ? $gd['GD Version'] : 'unknown'; +} +if (extension_loaded('imagick')) +{ + try { + $ver = Imagick::getVersion(); + $gfx_imagick = isset($ver['versionString']) ? $ver['versionString'] : 'unknown'; + } catch (Exception $e) { + $gfx_imagick = 'error: ' . $e->getMessage(); + } +} + +// CLI tools +if ($exec_available) +{ + $ffmpeg = companion_detect_tool('ffmpeg', '-version'); + $ffprobe = companion_detect_tool('ffprobe', '-version'); + $exiftool = companion_detect_tool('exiftool', '-ver'); + $mediainfo = companion_detect_tool('mediainfo', '--Version'); +} +else +{ + $notice = 'exec() is disabled — CLI tools cannot be detected'; + $ffmpeg = array('installed' => false, 'notice' => $notice); + $ffprobe = array('installed' => false, 'notice' => $notice); + $exiftool = array('installed' => false, 'notice' => $notice); + $mediainfo = array('installed' => false, 'notice' => $notice); +} + +// Piwigo config +$upload_all = isset($conf['upload_form_all_types']) ? (bool)$conf['upload_form_all_types'] : false; +$file_ext = isset($conf['file_ext']) ? $conf['file_ext'] : array(); +$video_exts_all = array('mp4', 'm4v', 'ogg', 'ogv', 'webm', 'webmv', 'mpg', 'mpeg', 'mov', 'avi'); +$found_video_exts = array_values(array_intersect($file_ext, $video_exts_all)); +$video_ready = $upload_all && !empty($found_video_exts); +$config_writable = companion_is_local_config_writable(); + +// VideoJS detection +// $plugins global: keys are plugin IDs, values contain plugin metadata. +// Active plugins only appear in $plugins; installed-but-inactive ones require a DB query. +global $plugins; +$videojs_installed = false; +$videojs_active = false; +$videojs_name = ''; + +// Helper: test if a string contains "videojs" or "video_js" +function companion_is_videojs($str) +{ + $s = strtolower($str); + return strpos($s, 'videojs') !== false || strpos($s, 'video_js') !== false; +} + +// 1. Search active plugins ($plugins key = plugin id) +if (!empty($plugins)) +{ + foreach ($plugins as $pid => $pdata) + { + $name = isset($pdata['name']) ? $pdata['name'] : $pid; + if (companion_is_videojs($pid) || companion_is_videojs($name)) + { + $videojs_installed = true; + $videojs_active = true; // present in $plugins → active + $videojs_name = $name; + break; + } + } +} + +// 2. If not found among active plugins, check installed-but-inactive via DB. +// PLUGINS_TABLE only has columns: id, state, version — no name column. +if (!$videojs_installed) +{ + $query = ' +SELECT id, state +FROM ' . PLUGINS_TABLE . ' +;'; + $result_db = pwg_query($query); + while ($row = pwg_db_fetch_assoc($result_db)) + { + if (companion_is_videojs($row['id'])) + { + $videojs_installed = true; + $videojs_active = ($row['state'] === 'active'); + $videojs_name = $row['id']; // no name in DB, use id + break; + } + } +} + +// ========================================================================= +// Theme detection (clear / dark) — same logic as centralAdmin +// ========================================================================= +$lrc_theme = 'clear'; +if (function_exists('userprefs_get_param')) +{ + $lrc_theme = (userprefs_get_param('admin_theme', 'clear') === 'roma') ? 'dark' : 'clear'; +} + +// ========================================================================= +// Assign to template +// ========================================================================= +$template->assign(array( + 'LRC_ADMIN_URL' => get_admin_plugin_menu_link(dirname(__FILE__).'/admin.php'), + 'PWG_TOKEN' => get_pwg_token(), + 'LRC_TAB' => $page['tab'], + + // Action result + 'LRC_ACTION_STATUS' => $action_status, + 'LRC_ACTION_MESSAGE' => $action_message, + + // PHP + 'LRC_PHP_VERSION' => $php['version'], + 'LRC_PHP_MEM' => $php['memory_limit'], + 'LRC_PHP_UPLOAD' => $php['upload_max_filesize'], + 'LRC_PHP_POST' => $php['post_max_size'], + 'LRC_PHP_MAXTIME' => $php['max_execution_time'], + 'LRC_PHP_EXEC' => $exec_available, + 'LRC_PHP_EXEC_NOTE' => $exec_available ? '' : 'exec() is disabled — contact your hosting provider', + + // Graphics + 'LRC_GD' => $gfx_gd, + 'LRC_IMAGICK' => $gfx_imagick, + + // CLI tools + 'LRC_FFMPEG_OK' => $ffmpeg['installed'], + 'LRC_FFMPEG_VER' => $ffmpeg['installed'] ? ($ffmpeg['version'] ?? 'Installed') : ($ffmpeg['notice'] ?? 'Not found'), + 'LRC_FFPROBE_OK' => $ffprobe['installed'], + 'LRC_FFPROBE_VER' => $ffprobe['installed'] ? ($ffprobe['version'] ?? 'Available') : ($ffprobe['notice'] ?? 'Not found'), + 'LRC_EXIFTOOL_OK' => $exiftool['installed'], + 'LRC_EXIFTOOL_VER' => $exiftool['installed'] ? ($exiftool['version'] ?? 'Installed') : ($exiftool['notice'] ?? 'Not found'), + 'LRC_MEDIAINFO_OK' => $mediainfo['installed'], + 'LRC_MEDIAINFO_VER' => $mediainfo['installed'] ? ($mediainfo['version'] ?? 'Installed') : ($mediainfo['notice'] ?? 'Not found'), + 'LRC_FFMPEG_NO_TPL' => (!$ffmpeg['installed'] && !isset($ffmpeg['notice'])), + + // Piwigo + 'LRC_PIWIGO_VER' => PHPWG_VERSION, + 'LRC_UPLOAD_ALL' => $upload_all, + 'LRC_VIDEO_EXTS' => implode(', ', $found_video_exts), + 'LRC_VIDEO_READY' => $video_ready, + 'LRC_CFG_WRITABLE' => $config_writable, + + // VideoJS + 'LRC_VJS_INSTALLED' => $videojs_installed, + 'LRC_VJS_ACTIVE' => $videojs_active, + 'LRC_VJS_NAME' => $videojs_name, + + // Theme + 'LRC_THEME' => $lrc_theme, + + // OS + 'LRC_OS' => PHP_OS, + 'LRC_WEBSERVER' => $_SERVER['SERVER_SOFTWARE'] ?? 'unknown', +)); + +// Render template +$template->set_filenames(array( + 'plugin_admin_content' => dirname(__FILE__) . '/admin.tpl', +)); +$template->assign_var_from_handle('ADMIN_CONTENT', 'plugin_admin_content'); diff --git a/lightroom-companion/admin.tpl b/lightroom-companion/admin.tpl new file mode 100644 index 0000000..99d4861 --- /dev/null +++ b/lightroom-companion/admin.tpl @@ -0,0 +1,293 @@ + + + + +
+ +

Lightroom Companion

+ + {* Tabsheet natif Piwigo *} + {include file='tabsheet.tpl'} + + {* ---- Action result ---- *} + {if $LRC_ACTION_STATUS eq 'ok' or $LRC_ACTION_STATUS eq 'already_configured'} +

{$LRC_ACTION_MESSAGE}

+ {elseif $LRC_ACTION_STATUS} +

{$LRC_ACTION_MESSAGE}

+ {/if} + + {* ================================================================= *} + {* TAB VIDEO *} + {* ================================================================= *} + {if $LRC_TAB eq 'video'} + + {* --- Statut global --- *} + {if $LRC_VIDEO_READY and $LRC_VJS_ACTIVE} +
+
+
+ Video support is fully active + Upload enabled & VideoJS plugin active — videos can be published from Lightroom. +
+
+ {else} +
+
!
+
+ Video support is not fully configured + Check the items below and fix each one. +
+
+ {/if} + + {* --- Upload Piwigo --- *} +
Video Upload (Piwigo)
+ + + + + + + + + + + + + +
Upload status + {if $LRC_VIDEO_READY} + Ready + {else} + Not configured + {/if} +
All file types + {if $LRC_UPLOAD_ALL} + Enabled + {else} + Disabled + {/if} +
Video extensions + {if $LRC_VIDEO_EXTS} + {$LRC_VIDEO_EXTS} + {else} + None configured + {/if} +
+ + {if not $LRC_VIDEO_READY} + {if $LRC_CFG_WRITABLE} +
+
+ + + +
+

Adds upload_form_all_types = true and video extensions (mp4, m4v, ogg, ogv, webm) to local/config/config.inc.php.

+
+ {else} +

Config file is not writable. Add manually to local/config/config.inc.php:

+
$conf['upload_form_all_types'] = true; +$conf['file_ext'] = array_merge($conf['picture_ext'], array('mp4', 'm4v', 'ogg', 'ogv', 'webm'));
+ {/if} + {/if} + + {* --- VideoJS plugin --- *} +
VideoJS Plugin
+ + {if $LRC_VJS_INSTALLED} + + + + + + + + + {else} + + + + + {/if} +
Plugin{$LRC_VJS_NAME}
Status + {if $LRC_VJS_ACTIVE} + Active + {else} + Installed but INACTIVE + {/if} +
VideoJSNot installed
+ {if not $LRC_VJS_INSTALLED} +

Install and activate the VideoJS plugin from Piwigo administration for in-gallery video playback.

+ {elseif not $LRC_VJS_ACTIVE} +

Activate VideoJS in Piwigo administration (Plugins menu) for video playback to work.

+ {/if} + + {/if}{* end tab video *} + + {* ================================================================= *} + {* TAB SERVER *} + {* ================================================================= *} + {if $LRC_TAB eq 'server'} + + {* --- CLI Tools --- *} +
Video & Media Tools
+ + + + + + + + + + + + + + + + + +
FFmpeg{$LRC_FFMPEG_VER}
FFprobe{$LRC_FFPROBE_VER}
ExifTool{$LRC_EXIFTOOL_VER}
MediaInfo{$LRC_MEDIAINFO_VER}
+ {if $LRC_FFMPEG_NO_TPL} +

Without FFmpeg, videos will upload but Piwigo will not generate a custom thumbnail for them.

+ {/if} + + {* --- Server & PHP --- *} +
Server & PHP
+ + + + + + + + + + + + +
OS{$LRC_OS}
Web Server{$LRC_WEBSERVER}
PHP Version{$LRC_PHP_VERSION}
upload_max_filesize{$LRC_PHP_UPLOAD}
post_max_size{$LRC_PHP_POST}
memory_limit{$LRC_PHP_MEM}
max_execution_time{$LRC_PHP_MAXTIME}s
exec() available + {if $LRC_PHP_EXEC} + Yes + {else} + No + {/if} +
+ {if $LRC_PHP_EXEC_NOTE} +

{$LRC_PHP_EXEC_NOTE}

+ {/if} + + {* --- Graphics --- *} +
Graphics Libraries
+ + + + + + + + + +
GD + {if $LRC_GD} + {$LRC_GD} + {else} + Not available + {/if} +
ImageMagick + {if $LRC_IMAGICK} + {$LRC_IMAGICK} + {else} + Not available + {/if} +
+ + {* --- Piwigo --- *} +
Piwigo Gallery
+ + + + + + +
Version{$LRC_PIWIGO_VER}
Config file writable + {if $LRC_CFG_WRITABLE} + Yes + {else} + No + {/if} +
+ + {/if}{* end tab server *} + +
diff --git a/lightroom-companion/main.inc.php b/lightroom-companion/main.inc.php new file mode 100644 index 0000000..62def8e --- /dev/null +++ b/lightroom-companion/main.inc.php @@ -0,0 +1,318 @@ + 'Lightroom Companion', + 'URL' => get_admin_plugin_menu_link(__DIR__ . '/admin.php'), + )); + return $menu; +} + +function companion_add_methods($arr) +{ + $service = &$arr[0]; + + $service->addMethod( + 'pwg.companion.getConfig', + 'companion_get_config', + array(), + 'Returns server configuration: PHP, upload limits, graphics libs, FFmpeg, video readiness.', + null, + array('admin_only' => true) + ); + + $service->addMethod( + 'pwg.companion.enableVideoSupport', + 'companion_enable_video_support', + array(), + 'Enables video upload support by writing upload_form_all_types and file_ext to local config.', + null, + array('admin_only' => true) + ); +} + +// ========================================================================= +// pwg.companion.getConfig +// ========================================================================= +function companion_get_config($params, &$service) +{ + $result = array(); + + // ----- PHP ----- + $disabled_functions = array_map('trim', explode(',', ini_get('disable_functions'))); + $exec_available = function_exists('exec') && !in_array('exec', $disabled_functions); + + $result['php'] = array( + 'version' => PHP_VERSION, + 'memory_limit' => ini_get('memory_limit'), + 'upload_max_filesize' => ini_get('upload_max_filesize'), + 'post_max_size' => ini_get('post_max_size'), + 'max_execution_time' => ini_get('max_execution_time'), + 'max_input_time' => ini_get('max_input_time'), + 'max_file_uploads' => ini_get('max_file_uploads'), + 'exec_available' => $exec_available, + 'disabled_functions' => $exec_available ? '' : ini_get('disable_functions'), + ); + + // ----- Graphics library ----- + $gfx = array('gd' => false, 'imagick' => false); + + if (function_exists('gd_info')) + { + $gd = gd_info(); + $gfx['gd'] = array( + 'version' => isset($gd['GD Version']) ? $gd['GD Version'] : 'unknown', + 'jpeg' => !empty($gd['JPEG Support']), + 'png' => !empty($gd['PNG Support']), + 'webp' => !empty($gd['WebP Support']), + ); + } + + if (extension_loaded('imagick')) + { + try { + $im = new Imagick(); + $ver = Imagick::getVersion(); + $gfx['imagick'] = array( + 'version' => isset($ver['versionString']) ? $ver['versionString'] : 'unknown', + ); + } catch (Exception $e) { + $gfx['imagick'] = array('version' => 'error: ' . $e->getMessage()); + } + } + + $result['graphics'] = $gfx; + + // ----- CLI tools (FFmpeg, ExifTool, MediaInfo) ----- + if ($exec_available) + { + $result['ffmpeg'] = companion_detect_tool('ffmpeg', '-version'); + $result['ffprobe'] = companion_detect_tool('ffprobe', '-version'); + $result['exiftool'] = companion_detect_tool('exiftool', '-ver'); + $result['mediainfo'] = companion_detect_tool('mediainfo', '--Version'); + } + else + { + $notice = 'exec() is disabled by PHP configuration'; + $result['ffmpeg'] = array('installed' => false, 'notice' => $notice); + $result['ffprobe'] = array('installed' => false, 'notice' => $notice); + $result['exiftool'] = array('installed' => false, 'notice' => $notice); + $result['mediainfo'] = array('installed' => false, 'notice' => $notice); + } + + // ----- Piwigo config (video-relevant) ----- + global $conf; + + $upload_all = isset($conf['upload_form_all_types']) ? (bool)$conf['upload_form_all_types'] : false; + $file_ext = isset($conf['file_ext']) ? $conf['file_ext'] : array(); + $pic_ext = isset($conf['picture_ext']) ? $conf['picture_ext'] : array(); + + // Check for video extensions + $video_exts = array('mp4', 'm4v', 'ogg', 'ogv', 'webm', 'webmv', 'mpg', 'mpeg', 'mov', 'avi'); + $found_video_exts = array_values(array_intersect($file_ext, $video_exts)); + + $result['piwigo'] = array( + 'version' => PHPWG_VERSION, + 'upload_form_all_types' => $upload_all, + 'file_ext' => $file_ext, + 'picture_ext' => $pic_ext, + 'video_ext_configured' => $found_video_exts, + 'video_ready' => $upload_all && !empty($found_video_exts), + 'local_config_writable' => companion_is_local_config_writable(), + ); + + // ----- OS ----- + $result['server'] = array( + 'os' => PHP_OS, + 'software' => isset($_SERVER['SERVER_SOFTWARE']) ? $_SERVER['SERVER_SOFTWARE'] : 'unknown', + ); + + return $result; +} + +// ========================================================================= +// pwg.companion.enableVideoSupport +// ========================================================================= +function companion_enable_video_support($params, &$service) +{ + global $conf; + + $config_path = PHPWG_ROOT_PATH . 'local/config/config.inc.php'; + + // Check if already configured + $upload_all = isset($conf['upload_form_all_types']) ? (bool)$conf['upload_form_all_types'] : false; + $file_ext = isset($conf['file_ext']) ? $conf['file_ext'] : array(); + $video_exts = array('mp4', 'm4v', 'ogg', 'ogv', 'webm'); + $found = array_intersect($file_ext, $video_exts); + + if ($upload_all && count($found) >= count($video_exts)) + { + return array( + 'status' => 'already_configured', + 'message' => 'Video support is already enabled.', + ); + } + + // Check writable + if (!companion_is_local_config_writable()) + { + return array( + 'status' => 'error', + 'message' => 'Cannot write to ' . $config_path . '. Check file permissions.', + ); + } + + // Read current file content + $content = ''; + if (file_exists($config_path)) + { + $content = file_get_contents($config_path); + } + + // Build lines to append + $lines_to_add = array(); + $lines_to_add[] = ''; + $lines_to_add[] = '// --- PiwigoPublish Companion: video upload support ---'; + + if (!$upload_all) + { + $lines_to_add[] = "\$conf['upload_form_all_types'] = true;"; + } + + // Always write file_ext with merge to ensure video extensions are present + $lines_to_add[] = "\$conf['file_ext'] = array_merge("; + $lines_to_add[] = " \$conf['picture_ext'],"; + $lines_to_add[] = " array('mp4', 'm4v', 'ogg', 'ogv', 'webm')"; + $lines_to_add[] = ");"; + + // Check if file has PHP opening tag + $php_open_tag = '<' . '?php'; + $php_close_tag = '?' . '>'; + if (empty($content) || strpos($content, $php_open_tag) === false) + { + $content = $php_open_tag . "\n" . implode("\n", $lines_to_add) . "\n"; + } + else + { + /* Remove trailing close-tag if present (we'll leave the file open) */ + $content = rtrim($content); + if (substr($content, -2) === $php_close_tag) + { + $content = rtrim(substr($content, 0, -2)); + } + $content .= "\n" . implode("\n", $lines_to_add) . "\n"; + } + + // Write + $written = @file_put_contents($config_path, $content); + if ($written === false) + { + return array( + 'status' => 'error', + 'message' => 'Failed to write to ' . $config_path, + ); + } + + return array( + 'status' => 'ok', + 'message' => 'Video support has been enabled. Video extensions (mp4, m4v, ogg, ogv, webm) are now allowed.', + ); +} + +// ========================================================================= +// Helpers +// ========================================================================= + +/** + * Check if local config file is writable (or parent dir is writable if file doesn't exist) + */ +function companion_is_local_config_writable() +{ + $config_path = PHPWG_ROOT_PATH . 'local/config/config.inc.php'; + if (file_exists($config_path)) + { + return is_writable($config_path); + } + // File doesn't exist — check if directory is writable + $dir = dirname($config_path); + return is_dir($dir) && is_writable($dir); +} + +/** + * Detect a CLI tool: find its path and get version output + */ +function companion_detect_tool($name, $version_flag) +{ + $result = array('installed' => false); + + $path = companion_find_executable($name); + if ($path === false) + { + return $result; + } + + $result['installed'] = true; + $result['path'] = $path; + + $output = array(); + @exec(escapeshellarg($path) . ' ' . $version_flag . ' 2>&1', $output); + if (!empty($output)) + { + $result['version'] = trim($output[0]); + } + + return $result; +} + +/** + * Try to find an executable in PATH or common locations + */ +function companion_find_executable($name) +{ + // Try which/where + $cmd = (PHP_OS_FAMILY === 'Windows') ? 'where' : 'which'; + $output = array(); + $return_var = -1; + @exec($cmd . ' ' . escapeshellarg($name) . ' 2>&1', $output, $return_var); + + if ($return_var === 0 && !empty($output)) + { + return trim($output[0]); + } + + // Fallback: common paths + $paths = array( + '/usr/bin/', + '/usr/local/bin/', + '/opt/bin/', + '/opt/local/bin/', + '/snap/bin/', + ); + + foreach ($paths as $path) + { + if (file_exists($path . $name)) + { + return $path . $name; + } + } + + return false; +} diff --git a/piwigoPublish.lrplugin/PiwigoAPI.lua b/piwigoPublish.lrplugin/PiwigoAPI.lua index 0710a62..cb9148d 100644 --- a/piwigoPublish.lrplugin/PiwigoAPI.lua +++ b/piwigoPublish.lrplugin/PiwigoAPI.lua @@ -1428,16 +1428,165 @@ function PiwigoAPI.getInfos(propertyTable) LrDialogs.message("Cannot get user status from Piwigo - " .. (getResponse.errorMessage or "Unknown error")) return false end - if getResponse.stat == "ok" then + if getResponse.status == "ok" then rtnStatus.status = true - rtnStatus.result = getResponse.result.infos + local apiResult = getResponse.response.result + -- pwg.getInfos returns a PwgNamedArray: {infos: [{name, value}, ...]} + -- Try both structures + if type(apiResult) == "table" then + if apiResult.infos then + rtnStatus.result = apiResult.infos + elseif #apiResult > 0 then + rtnStatus.result = apiResult + else + rtnStatus.result = apiResult + end + end + log:info("PiwigoAPI.getInfos - result keys: " .. utils.serialiseVar(apiResult)) else - rtnStatus.message = "Cannot get host information from Piwigo - " .. - ((getResponse.stat .. " - Error" .. getResponse.err .. "- " .. getResponse.errorMessage) or "Unknown error") + 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 = {}, + } + + -- 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 + 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 + 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 + -- ************************************************* function PiwigoAPI.getCommentInfos(propertyTable) -- return output from pwg.userComments.getList @@ -2198,15 +2347,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, { @@ -2237,9 +2396,21 @@ function PiwigoAPI.updateGallery(propertyTable, exportFilename, metaData) 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 and response.stat == "ok" then diff --git a/piwigoPublish.lrplugin/PublishDialogSections.lua b/piwigoPublish.lrplugin/PublishDialogSections.lua index 20ede8f..07aa653 100644 --- a/piwigoPublish.lrplugin/PublishDialogSections.lua +++ b/piwigoPublish.lrplugin/PublishDialogSections.lua @@ -492,6 +492,312 @@ local function prefsDialog(f, propertyTable) }, f:spacer { height = 1 }, + f:row { + f:push_button { + title = "Server Info\n ", + font = "", + width = share 'buttonwidth', + enabled = bind('Connected', 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 + + -- Helper: font for status display + local function statusFont(ok) + return ok and "" or "" + 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 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 + + 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)) + end + end + + table.insert(rows, dlgF:spacer { height = 6 }) + + -- ===== Server & PHP ===== + if cfg then + table.insert(rows, mkSectionHeader("Server && PHP")) + 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")) + end + + table.insert(rows, dlgF:spacer { height = 6 }) + + -- ===== Graphics ===== + table.insert(rows, mkSectionHeader("Graphics Libraries")) + 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", "")) + 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 + + 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 + + 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 + + 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 + + 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.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, 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 + + 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 + + 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) + + 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, + }, + f:static_text { + title = "Show server capabilities and video support status", + font = "", + alignment = 'left', + tooltip = "Display server information including Piwigo version, statistics, and video plugin status" + }, + }, + f:spacer { height = 1 }, + }, f:group_box { diff --git a/piwigoPublish.lrplugin/PublishServiceProvider.lua b/piwigoPublish.lrplugin/PublishServiceProvider.lua index f5f1b68..a4f6a06 100644 --- a/piwigoPublish.lrplugin/PublishServiceProvider.lua +++ b/piwigoPublish.lrplugin/PublishServiceProvider.lua @@ -39,7 +39,11 @@ return { -- Behaviour Settings allowFileFormats = { "JPEG", "PNG" }, allowColorSpaces = nil, - canExportVideo = false, + canExportVideo = true, + allowVideoExportPresets = { + { formatID = "h.264" }, + { formatID = "original" }, + }, supportsCustomSortOrder = true, hidePrintResolution = true, supportsIncrementalPublish = 'only', -- plugin only visible in publish services, not export diff --git a/piwigoPublish.lrplugin/PublishTask.lua b/piwigoPublish.lrplugin/PublishTask.lua index d2257ba..bc2c717 100644 --- a/piwigoPublish.lrplugin/PublishTask.lua +++ b/piwigoPublish.lrplugin/PublishTask.lua @@ -328,6 +328,114 @@ function PublishTask.processRenderedPhotos(functionContext, exportContext) -- flag to allow sync comments to manage process in PublishTask.getCommentsFromPublishedCollection PWStatusManager.setRenderPhotos(publishService, true) + -- Video upload guard: pre-check before rendering starts + local videoUploadBlocked = false + local serverMaxBytes = nil + + -- Pre-scan: detect if batch contains videos and check server support BEFORE rendering + local batchVideoCount = 0 + local batchTotalCount = exportSession:countRenditions() + local videoPhotos = {} + for photo in exportSession:photosToExport() do + local fmt = photo:getRawMetadata("fileFormat") + if fmt == "VIDEO" then + batchVideoCount = batchVideoCount + 1 + table.insert(videoPhotos, photo) + end + end + 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) + -- Check server video support now, before rendering starts + local videoSupport = PiwigoAPI.getServerVideoSupport(propertyTable) + local warnings = {} + + if not videoSupport.status then + videoUploadBlocked = true + table.insert(warnings, "- Cannot verify server video support (connection issue).") + elseif not videoSupport.companionAvailable then + 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 + local cfg = videoSupport.serverConfig + if cfg and cfg.piwigo then + if cfg.piwigo.video_ready then + log:info("PublishTask - 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("PublishTask - 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 + + -- FFmpeg absence is non-blocking, info available via Companion admin page + if cfg.ffmpeg and not cfg.ffmpeg.installed then + log:info("PublishTask - 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 + local warningText = table.concat(warnings, "\n") + -- Remove blocked videos from session BEFORE rendering starts + for _, vPhoto in ipairs(videoPhotos) do + local vName = vPhoto:getFormattedMetadata("fileName") or "unknown" + log:info("PublishTask - removing blocked video from session: " .. vName) + exportSession:removePhoto(vPhoto) + end + -- If batch contained ONLY videos, skip rendering entirely + if batchVideoCount >= batchTotalCount then + log:info("PublishTask - batch contained only videos, all blocked — nothing to render") + LrDialogs.message("Video Upload Blocked", + "Video upload is not authorized:\n\n" .. warningText .. + "\n\nNo photos to publish in this batch.", + "critical") + PWStatusManager.setPiwigoBusy(publishService, false) + PWStatusManager.setRenderPhotos(publishService, false) + return + else + LrDialogs.message("Video Upload Blocked", + "Video upload is not authorized:\n\n" .. warningText .. + "\n\n" .. batchVideoCount .. " video(s) skipped.\nPhotos will still be published.", + "critical") + end + elseif #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 + -- 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 @@ -349,10 +457,45 @@ function PublishTask.processRenderedPhotos(functionContext, exportContext) local lrPhoto = rendition.photo local remoteId = rendition.publishedPhotoId or "" + local fileFormat = lrPhoto:getRawMetadata("fileFormat") + local isVideo = (fileFormat == "VIDEO") + + -- Video guard: server-blocked videos already removed before the loop via removePhoto + local videoBlocked = false + if isVideo then + local videoFileName = lrPhoto:getFormattedMetadata("fileName") or "unknown" + log:info("PublishTask.processRenderedPhotos - video detected: " .. videoFileName) + + -- Per-file size check (only if upload allowed and limit known) + if serverMaxBytes then + local filePath = lrPhoto: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("PublishTask - BLOCKING video (too large): " .. videoFileName + .. " (" .. sizeMB .. " MB > " .. limitMB .. " MB)") + local skipOk, skipErr = pcall(function() rendition:skipRender() end) + if not skipOk then + log:info("PublishTask - skipRender failed (" .. tostring(skipErr) .. "), waiting for render to discard") + local bSuccess, bPath = rendition:waitForRender() + if bSuccess and LrFileUtils.exists(bPath) then LrFileUtils.delete(bPath) end + rendition:renditionIsDone(false, "Video too large: " .. sizeMB .. " MB (limit: " .. limitMB .. " MB)") + end + videoBlocked = true + LrDialogs.message("Video Too Large", + videoFileName .. " (" .. sizeMB .. " MB) exceeds the server limit of " .. limitMB .. " MB.\n" + .. "This video will be skipped.", + "warning") + end + end + end + end - -- Keyword filter check + -- Keyword filter check (skip if video already blocked) local kwBlocked = false - if kwFilterActive then + if not videoBlocked and kwFilterActive then local keywords = utils.getPhotoDirectKeywords(lrPhoto) local allowed, reason = utils.checkKeywordFilter(keywords, includePatterns, excludePatterns) if not allowed then @@ -369,7 +512,7 @@ function PublishTask.processRenderedPhotos(functionContext, exportContext) end end - if not kwBlocked then + if not kwBlocked and not videoBlocked then -- Detect photo already published in this service (multi-album support) local existingPwImageId = nil if remoteId == "" then @@ -377,30 +520,20 @@ function PublishTask.processRenderedPhotos(functionContext, exportContext) local storedImageUrl = lrPhoto:getPropertyForPlugin(_PLUGIN, "pwImageURL") local storedHost = lrPhoto:getPropertyForPlugin(_PLUGIN, "pwHostURL") - log:info("DEBUG multi-album: remoteId vide, checking metadata...") - log:info("DEBUG storedHost: " .. tostring(storedHost)) - log:info("DEBUG storedImageUrl: " .. tostring(storedImageUrl)) - log:info("DEBUG propertyTable.host: " .. tostring(propertyTable.host)) - 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 - log:info("DEBUG multi-album: metadata vides, recherche cross-collection...") local publishService = publishedCollection:getService() existingPwImageId = utils.findExistingPwImageId(publishService, lrPhoto) - if existingPwImageId then - log:info("DEBUG multi-album: trouvé via cross-collection, ID = " .. tostring(existingPwImageId)) - end end -- Verify the image still exists on Piwigo if existingPwImageId then local checkStatus = PiwigoAPI.checkPhoto(propertyTable, existingPwImageId) if not checkStatus.status then - log:info("DEBUG multi-album: image " .. existingPwImageId .. " n'existe plus sur Piwigo") existingPwImageId = nil end end diff --git a/piwigoPublish.lrplugin/utils.lua b/piwigoPublish.lrplugin/utils.lua index 15e4bfa..83bf2cb 100644 --- a/piwigoPublish.lrplugin/utils.lua +++ b/piwigoPublish.lrplugin/utils.lua @@ -1480,4 +1480,20 @@ 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 + return utils From 7e41f144b16ddc5b25b0f1f58b62af89b16dfe3e Mon Sep 17 00:00:00 2001 From: Gotcha26 Date: Tue, 17 Feb 2026 03:12:04 +0100 Subject: [PATCH 17/51] fix: #55 - Disable video support over Lr & precheck for the video size BEFORE anything. --- piwigoPublish.lrplugin/PublishTask.lua | 207 ++++++++++++++----------- 1 file changed, 118 insertions(+), 89 deletions(-) diff --git a/piwigoPublish.lrplugin/PublishTask.lua b/piwigoPublish.lrplugin/PublishTask.lua index bc2c717..15a70c0 100644 --- a/piwigoPublish.lrplugin/PublishTask.lua +++ b/piwigoPublish.lrplugin/PublishTask.lua @@ -347,64 +347,88 @@ function PublishTask.processRenderedPhotos(functionContext, exportContext) log:info("PublishTask - pre-scan: no videos detected in batch") else log:info("PublishTask - pre-scan: " .. batchVideoCount .. " video(s) detected in batch of " .. batchTotalCount) - -- Check server video support now, before rendering starts - local videoSupport = PiwigoAPI.getServerVideoSupport(propertyTable) - local warnings = {} - if not videoSupport.status then - videoUploadBlocked = true - table.insert(warnings, "- Cannot verify server video support (connection issue).") - elseif not videoSupport.companionAvailable then + -- Check if user disabled video inclusion in publish settings + if propertyTable.LR_includeVideoFiles == false then + log:info("PublishTask - video inclusion disabled by user (LR_includeVideoFiles = 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 - local cfg = videoSupport.serverConfig - if cfg and cfg.piwigo then - if cfg.piwigo.video_ready then - log:info("PublishTask - 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 + for _, vPhoto in ipairs(videoPhotos) do + local vName = vPhoto:getFormattedMetadata("fileName") or "unknown" + log:info("PublishTask - removing video (disabled by user): " .. vName) + exportSession:removePhoto(vPhoto) + end + if batchVideoCount >= batchTotalCount then + log:info("PublishTask - batch contained only videos, all disabled — nothing to render") + 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 + end + end + + -- Check server video support (skip if already blocked by user setting) + local warnings = {} + if not videoUploadBlocked then + 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 + 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 + local cfg = videoSupport.serverConfig + if cfg and cfg.piwigo then + if cfg.piwigo.video_ready then + log:info("PublishTask - 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("PublishTask - server max upload = " .. serverMaxBytes .. " bytes") + end end - if serverMaxBytes then - log:info("PublishTask - server max upload = " .. serverMaxBytes .. " bytes") + 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 - 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 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 - -- FFmpeg absence is non-blocking, info available via Companion admin page - if cfg.ffmpeg and not cfg.ffmpeg.installed then - log:info("PublishTask - FFmpeg not installed (non-blocking)") + -- FFmpeg absence is non-blocking, info available via Companion admin page + if cfg.ffmpeg and not cfg.ffmpeg.installed then + log:info("PublishTask - FFmpeg not installed (non-blocking)") + end + else + videoUploadBlocked = true + table.insert(warnings, "- Companion plugin responded but returned no configuration data.") end - else - videoUploadBlocked = true - table.insert(warnings, "- Companion plugin responded but returned no configuration data.") end end if videoUploadBlocked then - local warningText = table.concat(warnings, "\n") -- Remove blocked videos from session BEFORE rendering starts for _, vPhoto in ipairs(videoPhotos) do local vName = vPhoto:getFormattedMetadata("fileName") or "unknown" @@ -413,26 +437,57 @@ function PublishTask.processRenderedPhotos(functionContext, exportContext) end -- If batch contained ONLY videos, skip rendering entirely 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("PublishTask - batch contained only videos, all blocked — nothing to render") - LrDialogs.message("Video Upload Blocked", - "Video upload is not authorized:\n\n" .. warningText .. - "\n\nNo photos to publish in this batch.", - "critical") + LrDialogs.message("Video Upload Blocked", reason, "critical") PWStatusManager.setPiwigoBusy(publishService, false) PWStatusManager.setRenderPhotos(publishService, false) return else - LrDialogs.message("Video Upload Blocked", - "Video upload is not authorized:\n\n" .. warningText .. - "\n\n" .. batchVideoCount .. " video(s) skipped.\nPhotos will still be published.", - "critical") + 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 + -- Server allows video — check per-file size BEFORE rendering + if serverMaxBytes then + local oversizedVideos = {} + for idx = #videoPhotos, 1, -1 do + local vPhoto = videoPhotos[idx] + 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("PublishTask - removing oversized video from session: " .. vName + .. " (" .. sizeMB .. " MB > " .. limitMB .. " MB)") + exportSession:removePhoto(vPhoto) + table.insert(oversizedVideos, vName .. " (" .. sizeMB .. " MB)") + 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 - elseif #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 @@ -460,37 +515,11 @@ function PublishTask.processRenderedPhotos(functionContext, exportContext) local fileFormat = lrPhoto:getRawMetadata("fileFormat") local isVideo = (fileFormat == "VIDEO") - -- Video guard: server-blocked videos already removed before the loop via removePhoto + -- Video: blocked/oversized videos already removed before the loop via removePhoto local videoBlocked = false if isVideo then - local videoFileName = lrPhoto:getFormattedMetadata("fileName") or "unknown" - log:info("PublishTask.processRenderedPhotos - video detected: " .. videoFileName) - - -- Per-file size check (only if upload allowed and limit known) - if serverMaxBytes then - local filePath = lrPhoto: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("PublishTask - BLOCKING video (too large): " .. videoFileName - .. " (" .. sizeMB .. " MB > " .. limitMB .. " MB)") - local skipOk, skipErr = pcall(function() rendition:skipRender() end) - if not skipOk then - log:info("PublishTask - skipRender failed (" .. tostring(skipErr) .. "), waiting for render to discard") - local bSuccess, bPath = rendition:waitForRender() - if bSuccess and LrFileUtils.exists(bPath) then LrFileUtils.delete(bPath) end - rendition:renditionIsDone(false, "Video too large: " .. sizeMB .. " MB (limit: " .. limitMB .. " MB)") - end - videoBlocked = true - LrDialogs.message("Video Too Large", - videoFileName .. " (" .. sizeMB .. " MB) exceeds the server limit of " .. limitMB .. " MB.\n" - .. "This video will be skipped.", - "warning") - end - end - end + log:info("PublishTask.processRenderedPhotos - video detected: " + .. (lrPhoto:getFormattedMetadata("fileName") or "unknown")) end -- Keyword filter check (skip if video already blocked) From 86b6f6af276e2bfec24bd9ae829aeb2d25ce3df9 Mon Sep 17 00:00:00 2001 From: Gotcha26 Date: Tue, 17 Feb 2026 10:38:42 +0100 Subject: [PATCH 18/51] fix: #56 - Refactor the Main Page --- piwigoPublish.lrplugin/PluginInfo.lua | 3 +- .../PluginInfoDialogSections.lua | 434 +++++++++--------- 2 files changed, 215 insertions(+), 222 deletions(-) diff --git a/piwigoPublish.lrplugin/PluginInfo.lua b/piwigoPublish.lrplugin/PluginInfo.lua index a20756d..3c798eb 100644 --- a/piwigoPublish.lrplugin/PluginInfo.lua +++ b/piwigoPublish.lrplugin/PluginInfo.lua @@ -29,6 +29,5 @@ return { startDialog = PluginInfoDialogSections.startDialog, endDialog = PluginInfoDialogSections.endDialog, - -- sectionsForTopOfDialog = PluginInfoDialogSections.sectionsForTopOfDialog, - sectionsForBottomOfDialog = PluginInfoDialogSections.sectionsForBottomOfDialog, + sectionsForTopOfDialog = PluginInfoDialogSections.sectionsForTopOfDialog, } \ No newline at end of file diff --git a/piwigoPublish.lrplugin/PluginInfoDialogSections.lua b/piwigoPublish.lrplugin/PluginInfoDialogSections.lua index 9f3d01e..55af29e 100644 --- a/piwigoPublish.lrplugin/PluginInfoDialogSections.lua +++ b/piwigoPublish.lrplugin/PluginInfoDialogSections.lua @@ -39,7 +39,7 @@ local GITHUB_URL = "https://github.com/Piwigo/PiwigoPublish-lrc-plugin" -- Reset plugin preferences (optionally filtered by prefix) local function resetPluginPrefs(prefix) log:info("resetPluginPrefs \n" .. utils.serialiseVar(prefs)) - for k, p in prefs:pairs() do + for k, _ in prefs:pairs() do if prefix then if k:find(prefix, 1, true) == 1 then prefs[k] = nil @@ -87,77 +87,160 @@ function PluginInfoDialogSections.startDialog(propertyTable) end -- ************************************************* -function PluginInfoDialogSections.sectionsForBottomOfDialog(f, propertyTable) +function PluginInfoDialogSections.sectionsForTopOfDialog(f, propertyTable) local bind = LrView.bind local share = LrView.share return { -- =================================== - -- STATUS SECTION + -- SECTION 1: PLUG-IN INFO -- =================================== { bind_to_object = propertyTable, - title = "Status", + title = "Plug-in Info", + synopsis = "Piwigo Publisher Plugin • Version " .. pluginVersion, + fill = 1, + spacing = f:control_spacing(), + -- Two-column header layout (left: icon+identity, right: credits) f:row { - f:column { - f:picture { - alignment = 'left', - value = iconPath, - }, - }, + spacing = f:dialog_spacing(), + + -- Left column: icon + plugin identity f:column { spacing = f:control_spacing(), - f:static_text { - title = "Piwigo Publisher", - alignment = 'left', - font = "", + f:row { + spacing = f:control_spacing(), + + f:picture { + alignment = 'left', + value = iconPath, + }, + + f:column { + spacing = f:label_spacing(), + + f:static_text { + title = "Piwigo Publisher", + font = "", + alignment = 'left', + width = 250, + }, + + -- Version @ UpdateStatus on one line, red if not up to date + f:view { + bind_to_object = propertyTable, + f:static_text { + title = LrView.bind { + key = 'updateStatus', + transform = function(value) + return pluginVersion .. " @ " .. (value or "") + end, + }, + font = "", + text_color = LrView.bind { + key = 'updateStatus', + transform = function(value) + if value and value ~= "Up to date" then + return LrColor(0.8, 0, 0) + end + return LrColor(0.5, 0.5, 0.5) + end, + }, + alignment = 'left', + }, + }, + }, }, f:row { - f:static_text { - title = "Version:", - alignment = 'right', - width = share 'label_width', + f:push_button { + title = "Visit Plugin Page…", + action = function() + LrHttp.openUrlInBrowser(GITHUB_URL) + end, }, + }, + + f:row { f:static_text { - title = pluginVersion, - alignment = 'left', + title = "Made in England with cider and cheddar cheese in Somerset,\n" .. + "the Land of the Summer People.", + font = "", + text_color = LrColor(0.5, 0.5, 0.5), + alignment = 'center', + fill_horizontal = 1, + height_in_lines = -1, }, }, + }, + -- Right column: credits (no outer border — plain column) + f:column { + fill_horizontal = 1, + spacing = f:label_spacing(), + margin_left = f:dialog_spacing(), + + -- Developer row f:row { + spacing = f:control_spacing(), f:static_text { - title = "Update Status:", + title = "Developer:", + width = share 'credit_label_width', alignment = 'right', - width = share 'label_width', }, - f:static_text { - title = bind 'updateStatus', - alignment = 'left', + f:column { + spacing = f:label_spacing(), + f:static_text { + title = "Fiona Boston", + alignment = 'left', + }, + f:static_text { + title = "fiona@fbphotography.uk", + alignment = 'left', + text_color = LrColor("blue"), + mouse_down = function() + LrHttp.openUrlInBrowser("mailto:fiona@fbphotography.uk") + end, + }, + f:push_button { + title = "Visit website…", + action = function() + LrHttp.openUrlInBrowser("https://gallery.fbphotography.uk/") + end, + }, }, }, + f:separator { fill_horizontal = 1 }, + + -- Contributor row f:row { + spacing = f:control_spacing(), f:static_text { - title = "Plugin page:", + title = "Contributor:", + width = share 'credit_label_width', alignment = 'right', - width = share 'label_width', }, f:column { + spacing = f:label_spacing(), + f:static_text { + title = "Julien Moreau", + alignment = 'left', + }, f:static_text { - title = GITHUB_URL, + title = "contact@julien-moreau.fr", alignment = 'left', text_color = LrColor("blue"), mouse_down = function() - LrHttp.openUrlInBrowser(GITHUB_URL) + LrHttp.openUrlInBrowser("mailto:contact@julien-moreau.fr") end, }, f:push_button { - title = "Visit...", + title = "Visit website…", action = function() - LrHttp.openUrlInBrowser(GITHUB_URL) + LrHttp.openUrlInBrowser("https://julien-moreau.fr") end, }, }, @@ -167,215 +250,126 @@ function PluginInfoDialogSections.sectionsForBottomOfDialog(f, propertyTable) }, -- =================================== - -- SELF UPDATE SECTION - -- =================================== - { - bind_to_object = propertyTable, - title = "Self Update", - - f:row { - f:checkbox { - value = bind 'checkUpdatesOnStartup', - title = "Check for updates to this plugin when Lightroom starts", - }, - }, - - f:row { - f:push_button { - title = "Check for updates now", - action = function() - UpdateChecker.checkForUpdates(false) -- silent = false - end, - }, - }, - }, - - -- =================================== - -- DEBUGGING SECTION + -- SECTION 2: PLUG-IN PREFERENCES -- =================================== { bind_to_object = propertyTable, - title = "Debugging", - - f:row { - f:static_text { - title = "If you have a problem with Piwigo Publisher then I'll probably ask you to activate the debug logging. This will save all sorts of useful information to the Lightroom console.", - width_in_chars = 60, - height_in_lines = 2, - alignment = 'left', - }, - }, - - f:row { - spacing = f:label_spacing(), - - f:radio_button { - value = bind 'debugEnabled', - checked_value = false, - title = "Do not log debug information", - }, - }, - - f:row { - spacing = f:label_spacing(), - - f:radio_button { - value = bind 'debugEnabled', - checked_value = true, - title = "Log debug information to the Lightroom console", - }, - }, + title = "Plug-in Preferences", + fill = 1, + spacing = f:control_spacing(), + + -- Updates group box + f:group_box { + title = "Updates ", + fill_horizontal = 1, + spacing = f:control_spacing(), - f:row { - spacing = f:label_spacing(), - - f:push_button { - title = "Show logfile", - enabled = bind 'debugEnabled', - action = function() - LrShell.revealInShell(utils.getLogfilePath()) - end, + f:row { + f:checkbox { + value = bind 'checkUpdatesOnStartup', + title = "Check for updates when Lightroom starts", + }, + f:spacer { fill_horizontal = 1 }, + f:push_button { + title = "Check now", + action = function() + UpdateChecker.checkForUpdates(false) + end, + }, }, }, - f:separator { fill_horizontal = 1 }, + -- Debug logging group box + f:group_box { + title = "Diagnostic Logging ", + fill_horizontal = 1, + spacing = f:control_spacing(), - f:row { - f:static_text { - title = "Unsafe area — Development only", - font = "", - alignment = 'left', - text_color = LrColor("red"), + f:row { + 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.", + fill_horizontal = 1, + height_in_lines = 2, + alignment = 'left', + text_color = LrColor(0.02, 0.15, 0.39), + }, }, - }, - f:row { - f:static_text { - title = "These options are intended for plugin development and troubleshooting only.", - width_in_chars = 60, - alignment = 'left', - text_color = LrColor(0.4, 0.4, 0.4), + f:row { + f:radio_button { + value = bind 'debugEnabled', + checked_value = false, + title = "Logging off", + }, + f:spacer { fill_horizontal = 1 }, }, - }, - f:row { - f:checkbox { - value = bind 'debugToFile', - enabled = bind 'debugEnabled', - }, - f:static_text { - title = "Log to file instead of console", - alignment = 'left', + f:row { + f:radio_button { + value = bind 'debugEnabled', + checked_value = true, + title = "Live view in Lightroom (Help → Debug Console)", + }, + f:spacer { fill_horizontal = 1 }, + f:push_button { + title = "Open log file", + enabled = bind 'debugEnabled', + action = function() + LrShell.revealInShell(utils.getLogfilePath()) + end, + }, }, - }, - f:row { - f:static_text { - title = utils.getLogfilePath(), + f:row { + f:checkbox { + value = bind 'debugToFile', + enabled = bind 'debugEnabled', + }, + f:static_text { + title = "Also save to log file on disk (recommended for sharing with support)", + alignment = 'left', + fill_horizontal = 1, + width_in_chars = 40, + }, }, }, - f:row { + -- Unsafe / developer group box + f:group_box { + title = "Unsafe area — Development only ", + fill_horizontal = 1, spacing = f:control_spacing(), - f:push_button { - title = "Reset Plugin Preferences…", - - action = function() - local result = LrDialogs.confirm( - "Reset Plugin Preferences", - "This will delete all saved settings for this plugin.\n\nThis cannot be undone.", - "Reset", - "Cancel" - ) - - if result == "ok" then - resetPluginPrefs() - LrDialogs.message( - "Preferences Reset", - "Plugin preferences have been cleared.", - "info" + f:row { + fill_horizontal = 1, + f:static_text { + title = "Intended for plugin development and troubleshooting only.", + fill_horizontal = 1, + alignment = 'left', + text_color = LrColor(0.85, 0.45, 0), + }, + f:push_button { + title = "Reset Preferences…", + action = function() + local result = LrDialogs.confirm( + "Reset Plugin Preferences", + "This will delete all saved settings for this plugin.\n\n" .. + "This cannot be undone.", + "Reset", + "Cancel" ) - end - end, - }, - }, - }, - - -- =================================== - -- ACKNOWLEDGEMENTS SECTION - -- =================================== - { - bind_to_object = propertyTable, - title = "Acknowledgements", - - f:row { - f:static_text { - title = "Developer:", - alignment = 'right', - width = share 'ack_label_width', - font = "", - }, - f:static_text { - title = "Fiona Boston", - alignment = 'left', - }, - }, - - f:row { - f:spacer { width = share 'ack_label_width' }, - f:static_text { - title = "fiona@fbphotography.uk", - alignment = 'left', - text_color = LrColor("blue"), - mouse_down = function() - LrHttp.openUrlInBrowser("mailto:fiona@fbphotography.uk") - end, - }, - }, - - f:row { - f:static_text { - title = string.rep("─", 70), - alignment = 'left', - text_color = LrColor("black"), - }, - }, - - f:row { - f:static_text { - title = "Contributor:", - alignment = 'right', - width = share 'ack_label_width', - font = "", - }, - f:static_text { - title = "Julien Moreau", - alignment = 'left', - }, - }, - - f:row { - f:spacer { width = share 'ack_label_width' }, - f:static_text { - title = "contact@julien-moreau.fr", - alignment = 'left', - text_color = LrColor("blue"), - mouse_down = function() - LrHttp.openUrlInBrowser("mailto:contact@julien-moreau.fr") - end, - }, - }, - - f:row { - f:spacer { width = share 'ack_label_width' }, - f:static_text { - title = "https://julien-moreau.fr", - alignment = 'left', - text_color = LrColor("blue"), - mouse_down = function() - LrHttp.openUrlInBrowser("https://julien-moreau.fr") - end, + if result == "ok" then + resetPluginPrefs() + LrDialogs.message( + "Preferences Reset", + "Plugin preferences have been cleared.", + "info" + ) + end + end, + }, }, }, }, From 139216f850a9e82e407be8375049b42858c3e110 Mon Sep 17 00:00:00 2001 From: Gotcha26 Date: Tue, 17 Feb 2026 11:16:59 +0100 Subject: [PATCH 19/51] fix: #56 - Refactor the Main Page --- piwigoPublish.lrplugin/PluginInfo.lua | 3 +- .../PluginInfoDialogSections.lua | 434 +++++++++--------- 2 files changed, 215 insertions(+), 222 deletions(-) diff --git a/piwigoPublish.lrplugin/PluginInfo.lua b/piwigoPublish.lrplugin/PluginInfo.lua index a20756d..3c798eb 100644 --- a/piwigoPublish.lrplugin/PluginInfo.lua +++ b/piwigoPublish.lrplugin/PluginInfo.lua @@ -29,6 +29,5 @@ return { startDialog = PluginInfoDialogSections.startDialog, endDialog = PluginInfoDialogSections.endDialog, - -- sectionsForTopOfDialog = PluginInfoDialogSections.sectionsForTopOfDialog, - sectionsForBottomOfDialog = PluginInfoDialogSections.sectionsForBottomOfDialog, + sectionsForTopOfDialog = PluginInfoDialogSections.sectionsForTopOfDialog, } \ No newline at end of file diff --git a/piwigoPublish.lrplugin/PluginInfoDialogSections.lua b/piwigoPublish.lrplugin/PluginInfoDialogSections.lua index 9f3d01e..55af29e 100644 --- a/piwigoPublish.lrplugin/PluginInfoDialogSections.lua +++ b/piwigoPublish.lrplugin/PluginInfoDialogSections.lua @@ -39,7 +39,7 @@ local GITHUB_URL = "https://github.com/Piwigo/PiwigoPublish-lrc-plugin" -- Reset plugin preferences (optionally filtered by prefix) local function resetPluginPrefs(prefix) log:info("resetPluginPrefs \n" .. utils.serialiseVar(prefs)) - for k, p in prefs:pairs() do + for k, _ in prefs:pairs() do if prefix then if k:find(prefix, 1, true) == 1 then prefs[k] = nil @@ -87,77 +87,160 @@ function PluginInfoDialogSections.startDialog(propertyTable) end -- ************************************************* -function PluginInfoDialogSections.sectionsForBottomOfDialog(f, propertyTable) +function PluginInfoDialogSections.sectionsForTopOfDialog(f, propertyTable) local bind = LrView.bind local share = LrView.share return { -- =================================== - -- STATUS SECTION + -- SECTION 1: PLUG-IN INFO -- =================================== { bind_to_object = propertyTable, - title = "Status", + title = "Plug-in Info", + synopsis = "Piwigo Publisher Plugin • Version " .. pluginVersion, + fill = 1, + spacing = f:control_spacing(), + -- Two-column header layout (left: icon+identity, right: credits) f:row { - f:column { - f:picture { - alignment = 'left', - value = iconPath, - }, - }, + spacing = f:dialog_spacing(), + + -- Left column: icon + plugin identity f:column { spacing = f:control_spacing(), - f:static_text { - title = "Piwigo Publisher", - alignment = 'left', - font = "", + f:row { + spacing = f:control_spacing(), + + f:picture { + alignment = 'left', + value = iconPath, + }, + + f:column { + spacing = f:label_spacing(), + + f:static_text { + title = "Piwigo Publisher", + font = "", + alignment = 'left', + width = 250, + }, + + -- Version @ UpdateStatus on one line, red if not up to date + f:view { + bind_to_object = propertyTable, + f:static_text { + title = LrView.bind { + key = 'updateStatus', + transform = function(value) + return pluginVersion .. " @ " .. (value or "") + end, + }, + font = "", + text_color = LrView.bind { + key = 'updateStatus', + transform = function(value) + if value and value ~= "Up to date" then + return LrColor(0.8, 0, 0) + end + return LrColor(0.5, 0.5, 0.5) + end, + }, + alignment = 'left', + }, + }, + }, }, f:row { - f:static_text { - title = "Version:", - alignment = 'right', - width = share 'label_width', + f:push_button { + title = "Visit Plugin Page…", + action = function() + LrHttp.openUrlInBrowser(GITHUB_URL) + end, }, + }, + + f:row { f:static_text { - title = pluginVersion, - alignment = 'left', + title = "Made in England with cider and cheddar cheese in Somerset,\n" .. + "the Land of the Summer People.", + font = "", + text_color = LrColor(0.5, 0.5, 0.5), + alignment = 'center', + fill_horizontal = 1, + height_in_lines = -1, }, }, + }, + -- Right column: credits (no outer border — plain column) + f:column { + fill_horizontal = 1, + spacing = f:label_spacing(), + margin_left = f:dialog_spacing(), + + -- Developer row f:row { + spacing = f:control_spacing(), f:static_text { - title = "Update Status:", + title = "Developer:", + width = share 'credit_label_width', alignment = 'right', - width = share 'label_width', }, - f:static_text { - title = bind 'updateStatus', - alignment = 'left', + f:column { + spacing = f:label_spacing(), + f:static_text { + title = "Fiona Boston", + alignment = 'left', + }, + f:static_text { + title = "fiona@fbphotography.uk", + alignment = 'left', + text_color = LrColor("blue"), + mouse_down = function() + LrHttp.openUrlInBrowser("mailto:fiona@fbphotography.uk") + end, + }, + f:push_button { + title = "Visit website…", + action = function() + LrHttp.openUrlInBrowser("https://gallery.fbphotography.uk/") + end, + }, }, }, + f:separator { fill_horizontal = 1 }, + + -- Contributor row f:row { + spacing = f:control_spacing(), f:static_text { - title = "Plugin page:", + title = "Contributor:", + width = share 'credit_label_width', alignment = 'right', - width = share 'label_width', }, f:column { + spacing = f:label_spacing(), + f:static_text { + title = "Julien Moreau", + alignment = 'left', + }, f:static_text { - title = GITHUB_URL, + title = "contact@julien-moreau.fr", alignment = 'left', text_color = LrColor("blue"), mouse_down = function() - LrHttp.openUrlInBrowser(GITHUB_URL) + LrHttp.openUrlInBrowser("mailto:contact@julien-moreau.fr") end, }, f:push_button { - title = "Visit...", + title = "Visit website…", action = function() - LrHttp.openUrlInBrowser(GITHUB_URL) + LrHttp.openUrlInBrowser("https://julien-moreau.fr") end, }, }, @@ -167,215 +250,126 @@ function PluginInfoDialogSections.sectionsForBottomOfDialog(f, propertyTable) }, -- =================================== - -- SELF UPDATE SECTION - -- =================================== - { - bind_to_object = propertyTable, - title = "Self Update", - - f:row { - f:checkbox { - value = bind 'checkUpdatesOnStartup', - title = "Check for updates to this plugin when Lightroom starts", - }, - }, - - f:row { - f:push_button { - title = "Check for updates now", - action = function() - UpdateChecker.checkForUpdates(false) -- silent = false - end, - }, - }, - }, - - -- =================================== - -- DEBUGGING SECTION + -- SECTION 2: PLUG-IN PREFERENCES -- =================================== { bind_to_object = propertyTable, - title = "Debugging", - - f:row { - f:static_text { - title = "If you have a problem with Piwigo Publisher then I'll probably ask you to activate the debug logging. This will save all sorts of useful information to the Lightroom console.", - width_in_chars = 60, - height_in_lines = 2, - alignment = 'left', - }, - }, - - f:row { - spacing = f:label_spacing(), - - f:radio_button { - value = bind 'debugEnabled', - checked_value = false, - title = "Do not log debug information", - }, - }, - - f:row { - spacing = f:label_spacing(), - - f:radio_button { - value = bind 'debugEnabled', - checked_value = true, - title = "Log debug information to the Lightroom console", - }, - }, + title = "Plug-in Preferences", + fill = 1, + spacing = f:control_spacing(), + + -- Updates group box + f:group_box { + title = "Updates ", + fill_horizontal = 1, + spacing = f:control_spacing(), - f:row { - spacing = f:label_spacing(), - - f:push_button { - title = "Show logfile", - enabled = bind 'debugEnabled', - action = function() - LrShell.revealInShell(utils.getLogfilePath()) - end, + f:row { + f:checkbox { + value = bind 'checkUpdatesOnStartup', + title = "Check for updates when Lightroom starts", + }, + f:spacer { fill_horizontal = 1 }, + f:push_button { + title = "Check now", + action = function() + UpdateChecker.checkForUpdates(false) + end, + }, }, }, - f:separator { fill_horizontal = 1 }, + -- Debug logging group box + f:group_box { + title = "Diagnostic Logging ", + fill_horizontal = 1, + spacing = f:control_spacing(), - f:row { - f:static_text { - title = "Unsafe area — Development only", - font = "", - alignment = 'left', - text_color = LrColor("red"), + f:row { + 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.", + fill_horizontal = 1, + height_in_lines = 2, + alignment = 'left', + text_color = LrColor(0.02, 0.15, 0.39), + }, }, - }, - f:row { - f:static_text { - title = "These options are intended for plugin development and troubleshooting only.", - width_in_chars = 60, - alignment = 'left', - text_color = LrColor(0.4, 0.4, 0.4), + f:row { + f:radio_button { + value = bind 'debugEnabled', + checked_value = false, + title = "Logging off", + }, + f:spacer { fill_horizontal = 1 }, }, - }, - f:row { - f:checkbox { - value = bind 'debugToFile', - enabled = bind 'debugEnabled', - }, - f:static_text { - title = "Log to file instead of console", - alignment = 'left', + f:row { + f:radio_button { + value = bind 'debugEnabled', + checked_value = true, + title = "Live view in Lightroom (Help → Debug Console)", + }, + f:spacer { fill_horizontal = 1 }, + f:push_button { + title = "Open log file", + enabled = bind 'debugEnabled', + action = function() + LrShell.revealInShell(utils.getLogfilePath()) + end, + }, }, - }, - f:row { - f:static_text { - title = utils.getLogfilePath(), + f:row { + f:checkbox { + value = bind 'debugToFile', + enabled = bind 'debugEnabled', + }, + f:static_text { + title = "Also save to log file on disk (recommended for sharing with support)", + alignment = 'left', + fill_horizontal = 1, + width_in_chars = 40, + }, }, }, - f:row { + -- Unsafe / developer group box + f:group_box { + title = "Unsafe area — Development only ", + fill_horizontal = 1, spacing = f:control_spacing(), - f:push_button { - title = "Reset Plugin Preferences…", - - action = function() - local result = LrDialogs.confirm( - "Reset Plugin Preferences", - "This will delete all saved settings for this plugin.\n\nThis cannot be undone.", - "Reset", - "Cancel" - ) - - if result == "ok" then - resetPluginPrefs() - LrDialogs.message( - "Preferences Reset", - "Plugin preferences have been cleared.", - "info" + f:row { + fill_horizontal = 1, + f:static_text { + title = "Intended for plugin development and troubleshooting only.", + fill_horizontal = 1, + alignment = 'left', + text_color = LrColor(0.85, 0.45, 0), + }, + f:push_button { + title = "Reset Preferences…", + action = function() + local result = LrDialogs.confirm( + "Reset Plugin Preferences", + "This will delete all saved settings for this plugin.\n\n" .. + "This cannot be undone.", + "Reset", + "Cancel" ) - end - end, - }, - }, - }, - - -- =================================== - -- ACKNOWLEDGEMENTS SECTION - -- =================================== - { - bind_to_object = propertyTable, - title = "Acknowledgements", - - f:row { - f:static_text { - title = "Developer:", - alignment = 'right', - width = share 'ack_label_width', - font = "", - }, - f:static_text { - title = "Fiona Boston", - alignment = 'left', - }, - }, - - f:row { - f:spacer { width = share 'ack_label_width' }, - f:static_text { - title = "fiona@fbphotography.uk", - alignment = 'left', - text_color = LrColor("blue"), - mouse_down = function() - LrHttp.openUrlInBrowser("mailto:fiona@fbphotography.uk") - end, - }, - }, - - f:row { - f:static_text { - title = string.rep("─", 70), - alignment = 'left', - text_color = LrColor("black"), - }, - }, - - f:row { - f:static_text { - title = "Contributor:", - alignment = 'right', - width = share 'ack_label_width', - font = "", - }, - f:static_text { - title = "Julien Moreau", - alignment = 'left', - }, - }, - - f:row { - f:spacer { width = share 'ack_label_width' }, - f:static_text { - title = "contact@julien-moreau.fr", - alignment = 'left', - text_color = LrColor("blue"), - mouse_down = function() - LrHttp.openUrlInBrowser("mailto:contact@julien-moreau.fr") - end, - }, - }, - - f:row { - f:spacer { width = share 'ack_label_width' }, - f:static_text { - title = "https://julien-moreau.fr", - alignment = 'left', - text_color = LrColor("blue"), - mouse_down = function() - LrHttp.openUrlInBrowser("https://julien-moreau.fr") - end, + if result == "ok" then + resetPluginPrefs() + LrDialogs.message( + "Preferences Reset", + "Plugin preferences have been cleared.", + "info" + ) + end + end, + }, }, }, }, From 17360d7d536460f78c97296a130f6ea31297e94d Mon Sep 17 00:00:00 2001 From: Gotcha26 Date: Tue, 17 Feb 2026 11:17:12 +0100 Subject: [PATCH 20/51] Phase 1A --- video-toolkit/INSTALL.md | 111 +++++ video-toolkit/presets/default.json | 115 ++++++ video-toolkit/requirements.txt | 12 + video-toolkit/src/__init__.py | 1 + video-toolkit/src/cli.py | 629 +++++++++++++++++++++++++++++ video-toolkit/src/config.py | 143 +++++++ video-toolkit/src/ffprobe.py | 248 ++++++++++++ video-toolkit/src/hasher.py | 36 ++ video-toolkit/src/presets.py | 273 +++++++++++++ video-toolkit/src/status.py | 261 ++++++++++++ video-toolkit/src/ui.py | 234 +++++++++++ video-toolkit/video_toolkit.py | 56 +++ 12 files changed, 2119 insertions(+) create mode 100644 video-toolkit/INSTALL.md create mode 100644 video-toolkit/presets/default.json create mode 100644 video-toolkit/requirements.txt create mode 100644 video-toolkit/src/__init__.py create mode 100644 video-toolkit/src/cli.py create mode 100644 video-toolkit/src/config.py create mode 100644 video-toolkit/src/ffprobe.py create mode 100644 video-toolkit/src/hasher.py create mode 100644 video-toolkit/src/presets.py create mode 100644 video-toolkit/src/status.py create mode 100644 video-toolkit/src/ui.py create mode 100644 video-toolkit/video_toolkit.py diff --git a/video-toolkit/INSTALL.md b/video-toolkit/INSTALL.md new file mode 100644 index 0000000..2a7fae4 --- /dev/null +++ b/video-toolkit/INSTALL.md @@ -0,0 +1,111 @@ +# Video Toolkit — Installation + +## Dépendances + +### Requis + +- **Python** 3.8+ +- **FFmpeg** 5.0+ (transccodage vidéo + analyse avec ffprobe) + +### Optionnel + +- **ExifTool** 12+ (copie de métadonnées — sans lui, les métadonnées ne sont pas copiées) + +## Installation par système + +### Windows + +#### Via winget (recommandé) +```bash +winget install ffmpeg +winget install exiftool +``` + +#### Via Chocolatey +```bash +choco install ffmpeg +choco install exiftool +``` + +#### Manuel +1. Télécharger FFmpeg : https://ffmpeg.org/download.html + - Extraire le ZIP dans `C:\ffmpeg\` + - Ajouter `C:\ffmpeg\bin` à la variable PATH (ou configurer dans le toolkit) + +2. Télécharger ExifTool : https://exiftool.org/ + - Mettre le `.exe` dans `C:\exiftool\` (ou un dossier dans 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 exiftool +``` + +### Linux (Fedora/RHEL) + +```bash +sudo dnf install python3 ffmpeg perl-Image-ExifTool +``` + +### Linux (Arch) + +```bash +sudo pacman -S python ffmpeg perl-image-exiftool +``` + +## Configuration du toolkit + +### Mode 1 : Auto-détection (recommandé) + +Les outils sont détectés automatiquement si : +- Ils sont dans le PATH système +- Ou aux emplacements courants (Windows: `C:\ffmpeg\bin\ffmpeg.exe`, etc.) + +Lancez le toolkit en mode interactif pour vérifier : +```bash +cd video-toolkit +python video_toolkit.py +# Menu "Outils" affichera l'état de chaque outil +``` + +### Mode 2 : Configurer manuellement + +Si l'auto-détection échoue, configurez les chemins dans le menu "Paramètres" du toolkit interactif. + +```bash +python video_toolkit.py +# → Paramètres (option 4) +# → Modifier FFmpeg path / FFprobe path / ExifTool path +``` + +Ou éditer directement `~/.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" +} +``` + +## Vérification + +```bash +python video_toolkit.py --mode probe --input sample_video.mp4 +``` + +Doit retourner un JSON avec résolution, durée, codecs, etc. + +```bash +python video_toolkit.py +# Menu interactif → Outils (option 3) pour vérifier l'état des dépendances +``` 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/requirements.txt b/video-toolkit/requirements.txt new file mode 100644 index 0000000..594a4b3 --- /dev/null +++ b/video-toolkit/requirements.txt @@ -0,0 +1,12 @@ +# Video Toolkit — Dépendances Python +# Aucune dépendance externe requise. +# Tous les modules utilisés sont de la bibliothèque standard Python 3.8+ +# +# Outils système requis (non-Python) : +# - FFmpeg 5.0+ (ffmpeg + ffprobe) +# - ExifTool 12+ (optionnel — copie de métadonnées) +# +# Installation outils : +# Windows : winget install ffmpeg OR https://ffmpeg.org/download.html +# macOS : brew install ffmpeg exiftool +# Linux : apt install ffmpeg libimage-exiftool-perl diff --git a/video-toolkit/src/__init__.py b/video-toolkit/src/__init__.py new file mode 100644 index 0000000..ddf3689 --- /dev/null +++ b/video-toolkit/src/__init__.py @@ -0,0 +1 @@ +# Video Toolkit — src package diff --git a/video-toolkit/src/cli.py b/video-toolkit/src/cli.py new file mode 100644 index 0000000..2c58903 --- /dev/null +++ b/video-toolkit/src/cli.py @@ -0,0 +1,629 @@ +""" +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 .status import StatusManager +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=["probe", "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("--verbose", action="store_true", help="Sortie détaillée") + p.add_argument("--dry-run", action="store_true", help="Simuler sans écrire") + 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 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 + + # Phase 1A : mode probe seulement — retourner les infos probe pour chaque vidéo + videos = batch.get("videos", []) + ffprobe_bin = cfg.resolve_tool("ffprobe") or "ffprobe" + prober = FFprobe(ffprobe_bin) + + results = [] + for item in videos: + input_file = item.get("input", "") + try: + info = prober.probe(input_file) + h = partial_hash(input_file) + d = info.to_dict() + d["hash"] = h + d["status"] = "ok" + results.append(d) + except (ProbeError, OSError) as e: + results.append({"input": input_file, "status": "error", "error": str(e)}) + + print(json.dumps({"results": results}, 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, cfg.get("vtk_dir_name", ".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-4): ")).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_presets() + elif choice == "3": + self._menu_tools() + elif choice == "4": + 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 & TEST")) + print(c.separator()) + print(c.menu_option("1", "Probe - Analyser une vidéo (résolution, codecs, durée...)")) + print() + print(c.title("CONFIGURATION")) + print(c.separator()) + print(c.menu_option("2", "Presets - Voir et gérer les presets vidéo")) + print(c.menu_option("3", "Outils - Vérifier FFmpeg / FFprobe / ExifTool")) + print(c.menu_option("4", "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") + self.fmt.aligned_output([ + ("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), + ]) + + # 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 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.")) + + 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() + 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 = 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()) + + 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() + 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-7): ")).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() + 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) + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +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: int) -> 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 + try: + r = subprocess.run([path, "-version"], capture_output=True, text=True, timeout=5) + 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 + + platform = "Windows" if sys.platform == "win32" else ("macOS" if sys.platform == "darwin" else "Linux") + methods = instructions.get(platform, 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..8b07ae2 --- /dev/null +++ b/video-toolkit/src/config.py @@ -0,0 +1,143 @@ +""" +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, + "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. + 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 Windows + if sys.platform == "win32": + candidates = _windows_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") + + candidates_map: dict[str, list[str]] = { + "ffmpeg": [ + "C:\\ffmpeg\\bin\\ffmpeg.exe", + f"{program_files}\\ffmpeg\\bin\\ffmpeg.exe", + f"{local_app}\\ffmpeg\\bin\\ffmpeg.exe", + ], + "ffprobe": [ + "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, []) diff --git a/video-toolkit/src/ffprobe.py b/video-toolkit/src/ffprobe.py new file mode 100644 index 0000000..ea41d48 --- /dev/null +++ b/video-toolkit/src/ffprobe.py @@ -0,0 +1,248 @@ +""" +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 + + +# --------------------------------------------------------------------------- +# 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... + + @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, + } + + +# --------------------------------------------------------------------------- +# 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, + ) + 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, + ) + 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, + ) + 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) + + 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, + ) + + +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/presets.py b/video-toolkit/src/presets.py new file mode 100644 index 0000000..ae68394 --- /dev/null +++ b/video-toolkit/src/presets.py @@ -0,0 +1,273 @@ +""" +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__} + 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.""" + path = Path(path or self._config_path) + if not path: + raise ValueError("Aucun chemin de configuration défini") + + 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/status.py b/video-toolkit/src/status.py new file mode 100644 index 0000000..e5dcae5 --- /dev/null +++ b/video-toolkit/src/status.py @@ -0,0 +1,261 @@ +""" +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, + ) -> 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, + } + + 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..6b2e245 --- /dev/null +++ b/video-toolkit/src/ui.py @@ -0,0 +1,234 @@ +""" +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 + + +# --------------------------------------------------------------------------- +# 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 + # Activation Windows + if sys.platform == "win32": + return _activate_windows_ansi() + # Unix : vérifier isatty + return hasattr(sys.stdout, "isatty") and sys.stdout.isatty() + + +# --------------------------------------------------------------------------- +# 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 and output_dir.startswith(plugin_path): + rel = output_dir[len(plugin_path):].lstrip("/\\") + 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(): + os.system("cls" if sys.platform == "win32" else "clear") + + +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/video_toolkit.py b/video-toolkit/video_toolkit.py new file mode 100644 index 0000000..852bb31 --- /dev/null +++ b/video-toolkit/video_toolkit.py @@ -0,0 +1,56 @@ +#!/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_batch, run_status, 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) + + # Mode non-interactif (avec --mode) + if args.mode: + if args.mode == "probe": + return run_probe(args, cfg) + if args.mode == "batch": + return run_batch(args, cfg) + if args.mode == "status": + return run_status(args, cfg) + if args.mode == "clean": + # Phase 1A : non implémenté encore + import json + print(json.dumps({"status": "error", "error": "Mode 'clean' disponible en Phase 1D"})) + return 1 + + # Mode interactif + cli = InteractiveCLI(cfg) + cli.run() + return 0 + + +if __name__ == "__main__": + sys.exit(main()) From a5722539557fa5c7c6b1c15dc2abb924861da3d8 Mon Sep 17 00:00:00 2001 From: Gotcha26 Date: Tue, 17 Feb 2026 11:20:06 +0100 Subject: [PATCH 21/51] Phase 1B --- video-toolkit/src/ffmpeg.py | 376 +++++++++++++++++++++++++++++++++ video-toolkit/src/processor.py | 330 +++++++++++++++++++++++++++++ 2 files changed, 706 insertions(+) create mode 100644 video-toolkit/src/ffmpeg.py create mode 100644 video-toolkit/src/processor.py diff --git a/video-toolkit/src/ffmpeg.py b/video-toolkit/src/ffmpeg.py new file mode 100644 index 0000000..4e53c64 --- /dev/null +++ b/video-toolkit/src/ffmpeg.py @@ -0,0 +1,376 @@ +""" +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 .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"): + self.binary = 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, + ) -> 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 + - progress_callback(pct: int) est appelé pendant le traitement (0..100) + """ + input_path = str(input_path) + output_path = str(output_path) + + if preset.is_origin: + cmd = self._build_remux_cmd(input_path, output_path) + 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 + ) + cmd = self._build_transcode_cmd( + input_path, output_path, preset, scale_filter + ) + + if dry_run: + return TranscodeResult( + input_path=input_path, + output_path=output_path, + width=src_width, + height=src_height, + duration=src_duration, + size=0, + ) + + 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, + ) + 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, + ) + 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, + ) -> list[str]: + vb = preset.video_bitrate + ab = preset.audio_bitrate + + cmd = [ + self.binary, + "-i", input_path, + # Vidéo + "-c:v", preset.video_codec, + "-profile:v", preset.h264_profile, + "-level:v", "4.0", + "-crf", str(preset.crf), + "-b:v", f"{vb}k", + "-maxrate", f"{int(vb * 1.2)}k", + "-bufsize", f"{vb * 2}k", + "-vf", scale_filter, + "-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", + ) + 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, + ) + 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/processor.py b/video-toolkit/src/processor.py new file mode 100644 index 0000000..59b203e --- /dev/null +++ b/video-toolkit/src/processor.py @@ -0,0 +1,330 @@ +""" +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 Hasher +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 = "" + + +# --------------------------------------------------------------------------- +# 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", + preset_manager: PresetManager | None = None, + thumbnail_timestamp_pct: int = 10, + thumbnail_max_width: int = 1280, + ): + self._ffmpeg = FFmpeg(ffmpeg_path) + self._ffprobe = FFprobe(ffprobe_path) + self._hasher = Hasher() + self._presets = preset_manager or PresetManager() + self._thumb_pct = thumbnail_timestamp_pct + self._thumb_max_w = thumbnail_max_width + + # ------------------------------------------------------------------- + # 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)) + + # --- 3. Hash source --- + src_hash = self._hasher.hash_file(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, + ) + + # --- 5. Chemins de sortie --- + stem = input_path.stem + suffix = preset.suffix # "_medium", "_small", "" (origin) + variant_name = f"{stem}{suffix}.mp4" if 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 + + 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, + ) + + # --- 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, + ) + 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: + th_result = self._ffmpeg.thumbnail( + input_path=input_path, + output_path=thumbnail_path, + duration=info.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. 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) + ) + + 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, + ) + + # ------------------------------------------------------------------- + # 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 _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, + ) From b05cc8c4ba0c147545c51bc3bd969d89e098e6ae Mon Sep 17 00:00:00 2001 From: Gotcha26 Date: Tue, 17 Feb 2026 11:21:51 +0100 Subject: [PATCH 22/51] Phase 1C --- video-toolkit/src/metadata.py | 390 +++++++++++++++++++++++++++++++++ video-toolkit/src/processor.py | 20 +- 2 files changed, 409 insertions(+), 1 deletion(-) create mode 100644 video-toolkit/src/metadata.py diff --git a/video-toolkit/src/metadata.py b/video-toolkit/src/metadata.py new file mode 100644 index 0000000..0b06dff --- /dev/null +++ b/video-toolkit/src/metadata.py @@ -0,0 +1,390 @@ +""" +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 + + +# --------------------------------------------------------------------------- +# 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, + ) + 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, + ) + 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, + ) + 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, + ) + 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/processor.py b/video-toolkit/src/processor.py index 59b203e..62aa289 100644 --- a/video-toolkit/src/processor.py +++ b/video-toolkit/src/processor.py @@ -24,6 +24,7 @@ from .ffmpeg import FFmpeg, FFmpegError from .ffprobe import FFprobe, ProbeError, VideoInfo from .hasher import Hasher +from .metadata import ExifTool from .presets import PresetManager, VideoPreset from .status import StatusManager, STATE_PROCESSING, STATE_COMPLETE, STATE_ERROR @@ -62,16 +63,20 @@ 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, ): self._ffmpeg = FFmpeg(ffmpeg_path) self._ffprobe = FFprobe(ffprobe_path) + self._exiftool = ExifTool(exiftool_path) self._hasher = Hasher() 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 @@ -240,7 +245,20 @@ def _transcode_progress(pct: int) -> None: t_info = status.get_thumbnail() thumb_size = t_info.get("size", 0) - # --- 9. Finalisation --- + # --- 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() From ef93732e3752f3bf7c4172d52818f87151468b4c Mon Sep 17 00:00:00 2001 From: Gotcha26 Date: Tue, 17 Feb 2026 11:25:33 +0100 Subject: [PATCH 23/51] Phase 1D --- video-toolkit/src/cli.py | 375 ++++++++++++++++++++++++++++++--- video-toolkit/video_toolkit.py | 9 +- 2 files changed, 344 insertions(+), 40 deletions(-) diff --git a/video-toolkit/src/cli.py b/video-toolkit/src/cli.py index 2c58903..29db33c 100644 --- a/video-toolkit/src/cli.py +++ b/video-toolkit/src/cli.py @@ -26,7 +26,8 @@ from .ffprobe import FFprobe, ProbeError from .hasher import partial_hash from .presets import PresetManager, PRESET_ORDER -from .status import StatusManager +from .processor import VideoProcessor +from .status import StatusManager, GlobalStatusFile, STATE_PROCESSING, STATE_COMPLETE, STATE_ERROR from .ui import Colors, OutputFormatter, clear_screen, pause # --------------------------------------------------------------------------- @@ -40,17 +41,20 @@ def build_parser() -> argparse.ArgumentParser: ) p.add_argument( "--mode", - choices=["probe", "batch", "status", "clean"], + choices=["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("--verbose", action="store_true", help="Sortie détaillée") - p.add_argument("--dry-run", action="store_true", help="Simuler sans écrire") + 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("--verbose", action="store_true", help="Sortie détaillée") + p.add_argument("--dry-run", action="store_true", dest="dry_run", help="Simuler sans écrire") return p @@ -87,6 +91,69 @@ def run_probe(args: argparse.Namespace, cfg: Config) -> int: 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 = 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) + + 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 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) # --------------------------------------------------------------------------- @@ -109,25 +176,139 @@ def run_batch(args: argparse.Namespace, cfg: Config) -> int: _json_error(f"Erreur lecture batch : {e}") return 1 - # Phase 1A : mode probe seulement — retourner les infos probe pour chaque vidéo videos = batch.get("videos", []) - ffprobe_bin = cfg.resolve_tool("ffprobe") or "ffprobe" - prober = FFprobe(ffprobe_bin) + 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) - results = [] + # Construire les jobs depuis le batch JSON + jobs = [] for item in videos: - input_file = item.get("input", "") - try: - info = prober.probe(input_file) - h = partial_hash(input_file) - d = info.to_dict() - d["hash"] = h - d["status"] = "ok" - results.append(d) - except (ProbeError, OSError) as e: - results.append({"input": input_file, "status": "error", "error": str(e)}) - - print(json.dumps({"results": results}, indent=2, ensure_ascii=False)) + 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, + "size": r.size, + "skipped": r.skipped, + "status": "error" if r.error else "ok", + } + if r.error: + entry["error"] = r.error + 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 @@ -177,7 +358,7 @@ def run(self) -> None: self._print_header() self._print_main_menu() - choice = input(self.c.prompt("Votre choix (0-4): ")).strip() + 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") @@ -185,10 +366,12 @@ def run(self) -> None: elif choice == "1": self._menu_probe() elif choice == "2": - self._menu_presets() + self._menu_process() elif choice == "3": - self._menu_tools() + self._menu_presets() elif choice == "4": + self._menu_tools() + elif choice == "5": self._menu_config() else: print(self.c.error(f'Choix invalide : "{choice}"')) @@ -217,15 +400,16 @@ def _print_header(self) -> None: def _print_main_menu(self) -> None: c = self.c - print(c.title("ANALYSE & TEST")) + 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("2", "Presets - Voir et gérer les presets vidéo")) - print(c.menu_option("3", "Outils - Vérifier FFmpeg / FFprobe / ExifTool")) - print(c.menu_option("4", "Paramètres - Configuration générale")) + 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() @@ -301,7 +485,114 @@ def _menu_probe(self) -> None: pause(self.c) return - # --- 2. Menu Presets --- + # --- 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 = 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() @@ -391,7 +682,7 @@ def _menu_tools(self) -> None: if choice == "0": return elif choice == "1": - self._menu_config() + self._menu_config() # option 5 du menu principal else: print(self.c.error(f'Choix invalide : "{choice}"')) pause(self.c) @@ -531,6 +822,20 @@ def _edit_presets_file(self) -> None: # Helpers # --------------------------------------------------------------------------- +def _build_processor(cfg: Config) -> VideoProcessor: + """Construit un VideoProcessor avec les chemins d'outils depuis la config.""" + from .presets import PresetManager as PM + 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=cfg.get("poster_timestamp_pct", 10), + thumbnail_max_width=cfg.get("thumbnail_width", 1280), + copy_metadata=cfg.get("copy_metadata", True), + ) + + def _json_error(msg: str) -> None: """Écrit une erreur JSON sur stderr (pour les appels Lightroom).""" import json as _json diff --git a/video-toolkit/video_toolkit.py b/video-toolkit/video_toolkit.py index 852bb31..05491cc 100644 --- a/video-toolkit/video_toolkit.py +++ b/video-toolkit/video_toolkit.py @@ -21,7 +21,7 @@ # 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_batch, run_status, InteractiveCLI +from src.cli import build_parser, run_probe, run_process, run_batch, run_status, run_clean, InteractiveCLI from src.config import Config @@ -36,15 +36,14 @@ def main() -> int: if args.mode: 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": - # Phase 1A : non implémenté encore - import json - print(json.dumps({"status": "error", "error": "Mode 'clean' disponible en Phase 1D"})) - return 1 + return run_clean(args, cfg) # Mode interactif cli = InteractiveCLI(cfg) From e4829443d57797c201859d4755e4752cbe3cf72b Mon Sep 17 00:00:00 2001 From: Gotcha26 Date: Tue, 17 Feb 2026 11:37:08 +0100 Subject: [PATCH 24/51] Phase 2 --- .../PublishDialogSections.lua | 248 +++++++++++++++++- .../PublishServiceProvider.lua | 9 + piwigoPublish.lrplugin/PublishTask.lua | 240 +++++++++++++++++ 3 files changed, 496 insertions(+), 1 deletion(-) diff --git a/piwigoPublish.lrplugin/PublishDialogSections.lua b/piwigoPublish.lrplugin/PublishDialogSections.lua index 07aa653..3ad4756 100644 --- a/piwigoPublish.lrplugin/PublishDialogSections.lua +++ b/piwigoPublish.lrplugin/PublishDialogSections.lua @@ -942,10 +942,256 @@ local function prefsDialog(f, propertyTable) } end -- +-- ************************************************* +-- Video Toolkit dialog section (Phase 2A) +-- ************************************************* +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)" } + +local function 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 + + return { + title = "Video Settings", + bind_to_object = propertyTable, + + f:group_box { + title = "Video Toolkit", + font = "", + fill_horizontal = 1, + + f:spacer { height = 2 }, + + -- Enable/disable toggle + f:row { + f:checkbox { + title = "Enable Video Toolkit (local transcoding)", + value = bind "vtkEnabled", + tooltip = "When enabled, videos are transcoded locally by the Video Toolkit before upload.", + }, + }, + + f:spacer { height = 4 }, + + -- Preset + poster settings (enabled only when vtkEnabled = true) + f:group_box { + title = "Encoding Settings", + font = "", + fill_horizontal = 1, + enabled = bind "vtkEnabled", + + f:spacer { height = 2 }, + + 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 { height = 2 }, + + 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:row { + f:static_text { + title = "Poster at:", + alignment = 'right', + width = share 'vtk_label_w', + }, + 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 }, + + -- Advanced paths (collapsible group_box) + f:group_box { + title = "Advanced — Tool Paths", + font = "", + 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)", + }, + }, + + 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)", + }, + }, + + 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)", + }, + }, + + 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 }, + }, + + f:spacer { height = 4 }, + + -- Status + action buttons + f:group_box { + title = "Status", + font = "", + fill_horizontal = 1, + enabled = bind "vtkEnabled", + + f:spacer { height = 2 }, + + f:row { + f:static_text { + title = LrView.bind { + keys = { "vtkEnabled", "vtkPythonPath", "vtkFFmpegPath" }, + operation = function(_, values, _) + if not values.vtkEnabled then + return "Video Toolkit disabled." + 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...", + font = "", + 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.nilOrEmpty(propertyTable.vtkPythonPath) + and "python" + or propertyTable.vtkPythonPath + local plugin = rawget(_G, "_PLUGIN") + local toolkitPath = LrPathUtils.child( + LrPathUtils.parent(plugin.path), + "video-toolkit/video_toolkit.py" + ) + local cmd = '"' .. python .. '" "' .. toolkitPath .. '" --mode probe 2>&1' + local result = LrTasks.execute(cmd) + if result == 0 then + LrDialogs.message("Video Toolkit", + "Video Toolkit found and working.\nPython and ffprobe are available.", + "info") + else + LrDialogs.message("Video Toolkit — Error", + "Could not run Video Toolkit.\n\nCheck Python and FFmpeg installation, or set explicit paths in Advanced settings.", + "critical") + end + end) + end, + }, + f:push_button { + title = "Pre-render Now...", + font = "", + width = share 'buttonwidth', + enabled = bind "vtkEnabled", + tooltip = "Pre-process all videos in the current publish service without publishing them.", + action = function(_) + LrDialogs.message("Pre-render", + "Pre-render will be available in a future update.\n\nFor now, videos are processed automatically during publish.", + "info") + end, + }, + }, + + f:spacer { height = 2 }, + }, + }, + } +end + -- ************************************************* function PublishDialogSections.sectionsForTopOfDialog(f, propertyTable) local conDlg = connectionDialog(f, propertyTable) local prefDlg = prefsDialog(f, propertyTable) + local videoDlg = 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 @@ -954,7 +1200,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 a4f6a06..7caa9cd 100644 --- a/piwigoPublish.lrplugin/PublishServiceProvider.lua +++ b/piwigoPublish.lrplugin/PublishServiceProvider.lua @@ -65,6 +65,15 @@ return { { key = "syncCommentsPubOnly", default = false }, { key = "KwFilterInclude", default = '' }, { key = "KwFilterExclude", default = '' }, + -- Video Toolkit settings (Phase 2B) + { key = "vtkEnabled", default = false }, + { key = "vtkDefaultPreset", default = "medium" }, + { key = "vtkGeneratePoster", default = true }, + { key = "vtkPosterTimestamp", default = 10 }, + { key = "vtkPythonPath", default = '' }, + { key = "vtkFFmpegPath", default = '' }, + { key = "vtkExifToolPath", default = '' }, + { key = "vtkPresetsFile", default = '' }, }, metadataThatTriggersRepublish = function(publishSettings, photoId, fieldName) diff --git a/piwigoPublish.lrplugin/PublishTask.lua b/piwigoPublish.lrplugin/PublishTask.lua index 15a70c0..7d75497 100644 --- a/piwigoPublish.lrplugin/PublishTask.lua +++ b/piwigoPublish.lrplugin/PublishTask.lua @@ -491,6 +491,166 @@ function PublishTask.processRenderedPhotos(functionContext, exportContext) end end + -- ----------------------------------------------------------------------- + -- Phase 2C/2D — Video Toolkit : lancement + polling + upload variantes + -- ----------------------------------------------------------------------- + -- vtkResults[i] = { photo, variantPath, thumbnailPath, status, error } + local vtkResults = {} + + if not videoUploadBlocked and batchVideoCount > 0 and propertyTable.vtkEnabled then + log:info("PublishTask - Video Toolkit enabled, processing " .. batchVideoCount .. " video(s)") + + -- Résoudre les chemins des outils + local python = (propertyTable.vtkPythonPath and propertyTable.vtkPythonPath ~= "") + and propertyTable.vtkPythonPath + or "python" + local toolkitScript = LrPathUtils.child( + LrPathUtils.parent(_PLUGIN.path), + "video-toolkit/video_toolkit.py" + ) + local preset = (propertyTable.vtkDefaultPreset and propertyTable.vtkDefaultPreset ~= "") + and propertyTable.vtkDefaultPreset + or "medium" + + -- Fichier statut global pour le polling + local statusFilePath = LrPathUtils.child( + LrPathUtils.getStandardFilePath("temp"), + "piwigoPublish_vtk_status.json" + ) + + -- Construire le fichier batch JSON + local batchFilePath = LrPathUtils.child( + LrPathUtils.getStandardFilePath("temp"), + "piwigoPublish_vtk_batch.json" + ) + + local batchVideos = {} + for _, vPhoto in ipairs(videoPhotos) do + local filePath = vPhoto:getRawMetadata("path") + if filePath then + table.insert(batchVideos, { + input = filePath, + preset = preset, + }) + end + end + + local batchData = { + videos = batchVideos, + status_file = statusFilePath, + } + + -- Écrire le fichier batch + local batchFile = io.open(batchFilePath, "w") + if batchFile then + batchFile:write(JSON:encode(batchData)) + batchFile:close() + end + + -- Construire les arguments optionnels + 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 "" + + -- Commande complète (bloquante — le toolkit s'arrête quand tout est traité) + local cmd = '"' .. python .. '" "' .. toolkitScript .. '"' + .. ' --mode batch' + .. ' --batch-file "' .. batchFilePath .. '"' + .. ' --status-file "' .. statusFilePath .. '"' + .. ffmpegArg .. exiftoolArg .. presetsArg + + log:info("PublishTask - VTK command: " .. cmd) + + -- Configurer la progression LrC pendant le traitement vidéo + progressScope:setCaption("Video Toolkit — Processing " .. batchVideoCount .. " video(s)...") + + -- Lancer en async + polling du fichier statut + local vtkDone = false + local vtkExitCode = nil + + LrTasks.startAsyncTask(function() + vtkExitCode = LrTasks.execute(cmd) + vtkDone = true + end) + + -- Polling toutes les 500ms + while not vtkDone do + LrTasks.sleep(0.5) + + if progressScope:isCanceled() then + log:info("PublishTask - VTK polling cancelled by user") + break + end + + -- Lire la progression depuis le fichier statut + local sf = io.open(statusFilePath, "r") + if sf then + local content = sf:read("*all") + sf:close() + local ok, statusData = pcall(function() return JSON:decode(content) end) + if ok and statusData then + local pct = tonumber(statusData.progress) or 0 + progressScope:setPortionComplete(pct, 100) + if statusData.current_file and statusData.current_file ~= "" then + local fname = LrPathUtils.leafName(statusData.current_file) + progressScope:setCaption("Video Toolkit — " .. fname) + end + end + end + end + + -- Lire les résultats du toolkit + if vtkExitCode == 0 or vtkExitCode == nil then + -- Lire le statut final + local sf = io.open(statusFilePath, "r") + if sf then + local content = sf:read("*all") + sf:close() + local ok, statusData = pcall(function() return JSON:decode(content) end) + if ok and statusData and statusData.results then + -- Construire vtkResults indexé par chemin source + local resultsByPath = {} + for _, r in ipairs(statusData.results) do + if r.input then + resultsByPath[r.input] = r + end + end + for _, vPhoto in ipairs(videoPhotos) do + local filePath = vPhoto:getRawMetadata("path") + local r = filePath and resultsByPath[filePath] + if r then + table.insert(vtkResults, { + photo = vPhoto, + variantPath = r.variant or "", + thumbnailPath = r.thumbnail or "", + status = r.status or "error", + error = r.error or "", + }) + else + table.insert(vtkResults, { + photo = vPhoto, + status = "error", + error = "No result from Video Toolkit for " .. (filePath or "?"), + }) + end + end + end + end + else + log:warn("PublishTask - VTK exited with code: " .. tostring(vtkExitCode)) + LrDialogs.message("Video Toolkit Error", + "Video Toolkit failed (exit code " .. tostring(vtkExitCode) .. ").\n\n" + .. "Videos will be skipped. Check Video Toolkit settings.", + "critical") + end + + progressScope:setCaption("Publishing to Piwigo...") + progressScope:setPortionComplete(0, 100) + end + -- 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 @@ -667,6 +827,86 @@ function PublishTask.processRenderedPhotos(functionContext, exportContext) end -- end if not kwBlocked end + -- ----------------------------------------------------------------------- + -- Phase 2D — Upload des variantes vidéo (post-boucle renditions) + -- ----------------------------------------------------------------------- + if #vtkResults > 0 then + log:info("PublishTask - uploading " .. #vtkResults .. " video variant(s)") + progressScope:setCaption("Uploading video variants...") + + 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 + log:warn("PublishTask - skipping video (toolkit error): " .. vName .. " — " .. (vr.error or "")) + else + log:info("PublishTask - uploading video variant: " .. vr.variantPath) + progressScope:setCaption("Uploading video: " .. vName) + progressScope:setPortionComplete(idx - 1, #vtkResults) + + -- Métadonnées depuis la vidéo originale dans le catalogue LrC + local metaData = utils.getPhotoMetadata(propertyTable, vPhoto) + metaData.Albumid = albumId + metaData.Remoteid = "" -- nouveau : pas de remoteId existant + + -- Upload de la variante + local uploadStatus = PiwigoAPI.updateGallery(propertyTable, vr.variantPath, metaData) + + if uploadStatus.status then + local imageId = uploadStatus.remoteid or "" + log:info("PublishTask - video variant uploaded, image_id=" .. imageId) + + -- Upload du poster si disponible (Phase 3 : pwg.companion.setRepresentative) + if vr.thumbnailPath ~= "" and LrFileUtils.exists(vr.thumbnailPath) then + log:info("PublishTask - poster available: " .. vr.thumbnailPath + .. " (upload via companion planned in Phase 3)") + -- TODO Phase 3 : PiwigoAPI.setRepresentative(propertyTable, imageId, vr.thumbnailPath) + end + + -- Mettre à jour les métadonnées sur Piwigo (titre, description, mots-clés) + metaData.Remoteid = imageId + PiwigoAPI.updateMetadata(propertyTable, vPhoto, metaData) + + -- Enregistrer l'ID publié sur la vidéo ORIGINALE dans LrC + -- (via withWriteAccessDo car nous sommes hors boucle renditions) + local remoteUrl = uploadStatus.remoteurl or "" + catalog:withWriteAccessDo("Record published video ID", function() + local publishedPhotos = publishedCollection:getPublishedPhotos() + for _, pubPhoto in ipairs(publishedPhotos) do + if pubPhoto:getPhoto().localIdentifier == vPhoto.localIdentifier then + pubPhoto:setRemoteId(imageId) + pubPhoto:setRemoteUrl(remoteUrl) + break + end + end + end, { timeout = 5 }) + + -- Stocker les métadonnées custom (pwImageURL, pwHostURL, etc.) + local pluginData = { + pwHostURL = propertyTable.host, + albumName = albumName, + albumUrl = albumUrl, + imageUrl = remoteUrl, + pwUploadDate = os.date("%Y-%m-%d"), + pwUploadTime = os.date("%H:%M:%S"), + pwCommentSync = "", + } + PiwigoAPI.storeMetaData(catalog, vPhoto, pluginData) + else + log:warn("PublishTask - video variant upload failed: " .. vName + .. " — " .. (uploadStatus.statusMsg or "")) + LrDialogs.message("Video Upload Failed", + "Could not upload video variant for:\n" .. vName + .. "\n\nError: " .. (uploadStatus.statusMsg or "Unknown error"), + "warning") + end + end + end + + progressScope:setPortionComplete(#vtkResults, #vtkResults) + end + progressScope:done() PWStatusManager.setPiwigoBusy(publishService, false) end From 38351ed2760d06075df489f16470bfa368b3c69e Mon Sep 17 00:00:00 2001 From: Gotcha26 Date: Tue, 17 Feb 2026 13:42:30 +0100 Subject: [PATCH 25/51] Phase 3 --- lightroom-companion/main.inc.php | 91 +++++++++++++ piwigoPublish.lrplugin/PiwigoAPI.lua | 175 +++++++++++++++++++++++++ piwigoPublish.lrplugin/PublishTask.lua | 66 +++++++--- 3 files changed, 314 insertions(+), 18 deletions(-) diff --git a/lightroom-companion/main.inc.php b/lightroom-companion/main.inc.php index 62def8e..a430b79 100644 --- a/lightroom-companion/main.inc.php +++ b/lightroom-companion/main.inc.php @@ -46,6 +46,21 @@ function companion_add_methods($arr) null, array('admin_only' => true) ); + + $service->addMethod( + 'pwg.companion.setRepresentative', + 'companion_set_representative', + array( + 'image_id' => array( + 'default' => null, + 'type' => WS_TYPE_INT, + 'info' => 'Piwigo image/video ID', + ), + ), + 'Upload a poster/thumbnail image as the representative for a video.', + null, + array('admin_only' => true) + ); } // ========================================================================= @@ -236,6 +251,82 @@ function companion_enable_video_support($params, &$service) ); } +// ========================================================================= +// pwg.companion.setRepresentative +// ========================================================================= +function companion_set_representative($params, &$service) +{ + global $conf; + + $image_id = (int)$params['image_id']; + if ($image_id <= 0) + { + return new PwgError(WS_ERR_INVALID_PARAM, 'image_id must be a positive integer'); + } + + // Verify image exists + $query = 'SELECT id, path FROM ' . IMAGES_TABLE . ' WHERE id = ' . $image_id . ';'; + $result = pwg_query($query); + $row = pwg_db_fetch_assoc($result); + if (!$row) + { + return new PwgError(404, 'Image ' . $image_id . ' not found'); + } + + // Expect an uploaded file named 'file' + if (empty($_FILES['file']) || $_FILES['file']['error'] !== UPLOAD_ERR_OK) + { + $err = isset($_FILES['file']['error']) ? $_FILES['file']['error'] : 'no file'; + return new PwgError(WS_ERR_INVALID_PARAM, 'No valid file uploaded (error: ' . $err . ')'); + } + + // Determine storage directory from existing image path + // path is relative to PHPWG_ROOT_PATH, e.g. "upload/2024/01/01/2024010...jpg" + $image_dir = PHPWG_ROOT_PATH . dirname($row['path']); + if (!is_dir($image_dir)) + { + return new PwgError(500, 'Image directory not found: ' . $image_dir); + } + + // Build representative filename: same basename, extension = uploaded file extension + $uploaded_ext = strtolower(pathinfo($_FILES['file']['name'], PATHINFO_EXTENSION)); + if (!in_array($uploaded_ext, array('jpg', 'jpeg', 'png', 'webp'))) + { + return new PwgError(WS_ERR_INVALID_PARAM, 'Poster must be jpg, jpeg, png or webp'); + } + + // Piwigo representative: same filename as image but with new extension + $image_basename = pathinfo($row['path'], PATHINFO_FILENAME); + $representative_filename = $image_basename . '.' . $uploaded_ext; + $representative_path = $image_dir . '/' . $representative_filename; + + if (!move_uploaded_file($_FILES['file']['tmp_name'], $representative_path)) + { + return new PwgError(500, 'Failed to move uploaded poster to ' . $representative_path); + } + + // Invalidate Piwigo derivative cache for this image + $query = 'UPDATE ' . IMAGES_TABLE + . " SET representative_ext = '" . pwg_db_real_escape_string($uploaded_ext) . "'" + . ' WHERE id = ' . $image_id . ';'; + pwg_query($query); + + // Delete cached derivatives so Piwigo regenerates thumbnails + $image_path = PHPWG_ROOT_PATH . $row['path']; + if (function_exists('delete_element_derivatives')) + { + $element_info = array('id' => $image_id, 'path' => $row['path']); + delete_element_derivatives($element_info); + } + + return array( + 'status' => 'ok', + 'image_id' => $image_id, + 'representative_ext' => $uploaded_ext, + 'representative_path' => $representative_filename, + ); +} + // ========================================================================= // Helpers // ========================================================================= diff --git a/piwigoPublish.lrplugin/PiwigoAPI.lua b/piwigoPublish.lrplugin/PiwigoAPI.lua index cb9148d..d477651 100644 --- a/piwigoPublish.lrplugin/PiwigoAPI.lua +++ b/piwigoPublish.lrplugin/PiwigoAPI.lua @@ -3295,5 +3295,180 @@ function PiwigoAPI.createHeadersForMultipartPut(propertyTable, boundary, length) } } end +-- ************************************************* +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 } + + local callStatus = { status = false, remoteid = "", remoteurl = "", statusMsg = "" } + chunkSizeBytes = chunkSizeBytes or (512 * 1024) + + local headers = {} + if propertyTable.cookieHeader ~= nil then + headers = { ["Cookie"] = propertyTable.cookieHeader } + 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() + + 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(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 + callStatus.statusMsg = "setRepresentative - failed: " .. (postResp.statusMsg or "") + log:info("PiwigoAPI." .. callStatus.statusMsg) + end + return callStatus +end + -- ************************************************* return PiwigoAPI diff --git a/piwigoPublish.lrplugin/PublishTask.lua b/piwigoPublish.lrplugin/PublishTask.lua index 7d75497..f8f1a2f 100644 --- a/piwigoPublish.lrplugin/PublishTask.lua +++ b/piwigoPublish.lrplugin/PublishTask.lua @@ -329,8 +329,9 @@ function PublishTask.processRenderedPhotos(functionContext, exportContext) PWStatusManager.setRenderPhotos(publishService, true) -- Video upload guard: pre-check before rendering starts - local videoUploadBlocked = false - local serverMaxBytes = nil + local videoUploadBlocked = false + local serverMaxBytes = nil + local companionAvailable = false -- Pre-scan: detect if batch contains videos and check server support BEFORE rendering local batchVideoCount = 0 @@ -379,12 +380,14 @@ function PublishTask.processRenderedPhotos(functionContext, exportContext) 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 @@ -850,26 +853,53 @@ function PublishTask.processRenderedPhotos(functionContext, exportContext) metaData.Albumid = albumId metaData.Remoteid = "" -- nouveau : pas de remoteId existant - -- Upload de la variante - local uploadStatus = PiwigoAPI.updateGallery(propertyTable, vr.variantPath, metaData) + -- 3B — Upload de la variante : + -- Si le fichier dépasse la limite PHP du serveur → upload chunked + -- Sinon → addSimple standard + 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( + "PublishTask - video %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("PublishTask - video " .. vName .. " → addSimple upload") + uploadStatus = PiwigoAPI.updateGallery(propertyTable, vr.variantPath, metaData) + end if uploadStatus.status then local imageId = uploadStatus.remoteid or "" log:info("PublishTask - video variant uploaded, image_id=" .. imageId) - -- Upload du poster si disponible (Phase 3 : pwg.companion.setRepresentative) - if vr.thumbnailPath ~= "" and LrFileUtils.exists(vr.thumbnailPath) then - log:info("PublishTask - poster available: " .. vr.thumbnailPath - .. " (upload via companion planned in Phase 3)") - -- TODO Phase 3 : PiwigoAPI.setRepresentative(propertyTable, imageId, vr.thumbnailPath) + -- 3C — Upload du poster via pwg.companion.setRepresentative + if vr.thumbnailPath and vr.thumbnailPath ~= "" + and LrFileUtils.exists(vr.thumbnailPath) then + if companionAvailable then + log:info("PublishTask - uploading poster: " .. vr.thumbnailPath) + progressScope:setCaption("Uploading poster: " .. vName) + local posterStatus = PiwigoAPI.setRepresentative( + propertyTable, imageId, vr.thumbnailPath) + if posterStatus.status then + log:info("PublishTask - poster set for image_id=" .. imageId) + else + log:warn("PublishTask - poster upload failed: " + .. (posterStatus.statusMsg or "")) + end + else + log:info("PublishTask - companion not available, skipping poster upload") + end end - -- Mettre à jour les métadonnées sur Piwigo (titre, description, mots-clés) + -- 3D — Mettre à jour les métadonnées sur Piwigo (titre, description, mots-clés) metaData.Remoteid = imageId PiwigoAPI.updateMetadata(propertyTable, vPhoto, metaData) - -- Enregistrer l'ID publié sur la vidéo ORIGINALE dans LrC - -- (via withWriteAccessDo car nous sommes hors boucle renditions) + -- 3D — Enregistrer l'ID publié sur la vidéo ORIGINALE dans LrC local remoteUrl = uploadStatus.remoteurl or "" catalog:withWriteAccessDo("Record published video ID", function() local publishedPhotos = publishedCollection:getPublishedPhotos() @@ -884,12 +914,12 @@ function PublishTask.processRenderedPhotos(functionContext, exportContext) -- Stocker les métadonnées custom (pwImageURL, pwHostURL, etc.) local pluginData = { - pwHostURL = propertyTable.host, - albumName = albumName, - albumUrl = albumUrl, - imageUrl = remoteUrl, - pwUploadDate = os.date("%Y-%m-%d"), - pwUploadTime = os.date("%H:%M:%S"), + pwHostURL = propertyTable.host, + albumName = albumName, + albumUrl = albumUrl, + imageUrl = remoteUrl, + pwUploadDate = os.date("%Y-%m-%d"), + pwUploadTime = os.date("%H:%M:%S"), pwCommentSync = "", } PiwigoAPI.storeMetaData(catalog, vPhoto, pluginData) From af74a2d1ee1f4ee9feca2704588a4ef717548880 Mon Sep 17 00:00:00 2001 From: Gotcha26 Date: Tue, 17 Feb 2026 13:52:28 +0100 Subject: [PATCH 26/51] Phase 4 --- piwigoPublish.lrplugin/CustomMetadata.lua | 11 +- piwigoPublish.lrplugin/PiwigoAPI.lua | 3 + piwigoPublish.lrplugin/PublishTask.lua | 186 ++++++++++++++++------ 3 files changed, 152 insertions(+), 48 deletions(-) diff --git a/piwigoPublish.lrplugin/CustomMetadata.lua b/piwigoPublish.lrplugin/CustomMetadata.lua index 7333e52..579ac66 100644 --- a/piwigoPublish.lrplugin/CustomMetadata.lua +++ b/piwigoPublish.lrplugin/CustomMetadata.lua @@ -23,7 +23,7 @@ return { - schemaVersion = 5, + schemaVersion = 6, metadataFieldsForPhotos = { { @@ -89,5 +89,14 @@ return { title = "pwCommentSync", version = 1 }, + { + dataType = 'string', + readOnly = true, + searchable = false, + browsable = false, + id = 'pwVideoPreset', + title = "Video Preset", + version = 6 + }, } } diff --git a/piwigoPublish.lrplugin/PiwigoAPI.lua b/piwigoPublish.lrplugin/PiwigoAPI.lua index d477651..0f44256 100644 --- a/piwigoPublish.lrplugin/PiwigoAPI.lua +++ b/piwigoPublish.lrplugin/PiwigoAPI.lua @@ -1082,6 +1082,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 diff --git a/piwigoPublish.lrplugin/PublishTask.lua b/piwigoPublish.lrplugin/PublishTask.lua index f8f1a2f..d63d204 100644 --- a/piwigoPublish.lrplugin/PublishTask.lua +++ b/piwigoPublish.lrplugin/PublishTask.lua @@ -336,12 +336,44 @@ function PublishTask.processRenderedPhotos(functionContext, exportContext) -- Pre-scan: detect if batch contains videos and check server support BEFORE rendering local batchVideoCount = 0 local batchTotalCount = exportSession:countRenditions() + -- videoPhotos[i] = { photo, existingImageId, appliedPreset, republishMode } + -- republishMode : "new" | "re_upload" | "metadata_only" local videoPhotos = {} for photo in exportSession:photosToExport() do local fmt = photo:getRawMetadata("fileFormat") if fmt == "VIDEO" then batchVideoCount = batchVideoCount + 1 - table.insert(videoPhotos, photo) + -- Detect republication context + 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 + appliedPreset = photo:getPropertyForPlugin(_PLUGIN, "pwVideoPreset") or "" + local currentPreset = (propertyTable.vtkDefaultPreset and propertyTable.vtkDefaultPreset ~= "") + and propertyTable.vtkDefaultPreset or "medium" + if appliedPreset ~= "" and appliedPreset ~= currentPreset then + -- Preset changed → need re-encode (or use cached variant if it exists) + republishMode = "re_upload" + else + -- Same preset (or no preset recorded) → check if source file changed via .vtk cache + -- If hash matches → metadata only; if not → re_upload + -- Default to metadata_only; the toolkit will detect hash mismatch and re-encode + republishMode = "metadata_only" + end + end + + table.insert(videoPhotos, { + photo = photo, + existingImageId = existingImageId, + appliedPreset = appliedPreset, + republishMode = republishMode, + }) end end if batchVideoCount == 0 then @@ -353,10 +385,10 @@ function PublishTask.processRenderedPhotos(functionContext, exportContext) if propertyTable.LR_includeVideoFiles == false then log:info("PublishTask - video inclusion disabled by user (LR_includeVideoFiles = false)") videoUploadBlocked = true - for _, vPhoto in ipairs(videoPhotos) do - local vName = vPhoto:getFormattedMetadata("fileName") or "unknown" + for _, vEntry in ipairs(videoPhotos) do + local vName = vEntry.photo:getFormattedMetadata("fileName") or "unknown" log:info("PublishTask - removing video (disabled by user): " .. vName) - exportSession:removePhoto(vPhoto) + exportSession:removePhoto(vEntry.photo) end if batchVideoCount >= batchTotalCount then log:info("PublishTask - batch contained only videos, all disabled — nothing to render") @@ -433,10 +465,10 @@ function PublishTask.processRenderedPhotos(functionContext, exportContext) if videoUploadBlocked then -- Remove blocked videos from session BEFORE rendering starts - for _, vPhoto in ipairs(videoPhotos) do - local vName = vPhoto:getFormattedMetadata("fileName") or "unknown" + for _, vEntry in ipairs(videoPhotos) do + local vName = vEntry.photo:getFormattedMetadata("fileName") or "unknown" log:info("PublishTask - removing blocked video from session: " .. vName) - exportSession:removePhoto(vPhoto) + exportSession:removePhoto(vEntry.photo) end -- If batch contained ONLY videos, skip rendering entirely if batchVideoCount >= batchTotalCount then @@ -455,22 +487,26 @@ function PublishTask.processRenderedPhotos(functionContext, exportContext) LrDialogs.message("Video Upload Blocked", reason, "critical") end else - -- Server allows video — check per-file size BEFORE rendering + -- Server allows video — check per-file size BEFORE rendering (only for new/re_upload) if serverMaxBytes then local oversizedVideos = {} for idx = #videoPhotos, 1, -1 do - local vPhoto = videoPhotos[idx] - 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("PublishTask - removing oversized video from session: " .. vName - .. " (" .. sizeMB .. " MB > " .. limitMB .. " MB)") - exportSession:removePhoto(vPhoto) - table.insert(oversizedVideos, vName .. " (" .. sizeMB .. " MB)") + local vEntry = videoPhotos[idx] + local vPhoto = vEntry.photo + -- metadata_only videos are never uploaded, skip size check + 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("PublishTask - removing oversized video from session: " .. vName + .. " (" .. sizeMB .. " MB > " .. limitMB .. " MB)") + table.remove(videoPhotos, idx) + table.insert(oversizedVideos, vName .. " (" .. sizeMB .. " MB)") + end end end end @@ -497,8 +533,9 @@ function PublishTask.processRenderedPhotos(functionContext, exportContext) -- ----------------------------------------------------------------------- -- Phase 2C/2D — Video Toolkit : lancement + polling + upload variantes -- ----------------------------------------------------------------------- - -- vtkResults[i] = { photo, variantPath, thumbnailPath, status, error } - local vtkResults = {} + -- vtkResults[i] = { photo, existingImageId, republishMode, variantPath, thumbnailPath, status, error } + local vtkResults = {} + local metadataOnlyVideos = {} -- 4C : videos needing metadata-only update if not videoUploadBlocked and batchVideoCount > 0 and propertyTable.vtkEnabled then log:info("PublishTask - Video Toolkit enabled, processing " .. batchVideoCount .. " video(s)") @@ -528,16 +565,28 @@ function PublishTask.processRenderedPhotos(functionContext, exportContext) ) local batchVideos = {} - for _, vPhoto in ipairs(videoPhotos) do - local filePath = vPhoto:getRawMetadata("path") + for _, vEntry in ipairs(videoPhotos) do + local filePath = vEntry.photo:getRawMetadata("path") if filePath then - table.insert(batchVideos, { - input = filePath, - preset = preset, - }) + if vEntry.republishMode == "metadata_only" then + table.insert(metadataOnlyVideos, vEntry) + else + -- "new" or "re_upload" : run through toolkit + -- force=true if preset changed (re_upload), false for new (toolkit decides via hash) + table.insert(batchVideos, { + input = filePath, + preset = preset, + force = (vEntry.republishMode == "re_upload"), + }) + end end end + -- Si toutes les vidéos sont metadata_only, skip le toolkit entièrement + if #batchVideos == 0 then + log:info("PublishTask - all videos are metadata-only, skipping Video Toolkit") + else + local batchData = { videos = batchVideos, status_file = statusFilePath, @@ -621,23 +670,29 @@ function PublishTask.processRenderedPhotos(functionContext, exportContext) resultsByPath[r.input] = r end end - for _, vPhoto in ipairs(videoPhotos) do - local filePath = vPhoto:getRawMetadata("path") - local r = filePath and resultsByPath[filePath] - if r then - table.insert(vtkResults, { - photo = vPhoto, - variantPath = r.variant or "", - thumbnailPath = r.thumbnail or "", - status = r.status or "error", - error = r.error or "", - }) - else - table.insert(vtkResults, { - photo = vPhoto, - status = "error", - error = "No result from Video Toolkit for " .. (filePath or "?"), - }) + 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 "", + 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 end @@ -652,6 +707,8 @@ function PublishTask.processRenderedPhotos(functionContext, exportContext) progressScope:setCaption("Publishing to Piwigo...") progressScope:setPortionComplete(0, 100) + + end -- if #batchVideos == 0 / else end -- now wait for photos to be exported and then upload to Piwigo @@ -851,7 +908,8 @@ function PublishTask.processRenderedPhotos(functionContext, exportContext) -- Métadonnées depuis la vidéo originale dans le catalogue LrC local metaData = utils.getPhotoMetadata(propertyTable, vPhoto) metaData.Albumid = albumId - metaData.Remoteid = "" -- nouveau : pas de remoteId existant + -- 4B : si republication, passer l'image_id existant pour que Piwigo remplace la vidéo + metaData.Remoteid = vr.existingImageId or "" -- 3B — Upload de la variante : -- Si le fichier dépasse la limite PHP du serveur → upload chunked @@ -912,7 +970,7 @@ function PublishTask.processRenderedPhotos(functionContext, exportContext) end end, { timeout = 5 }) - -- Stocker les métadonnées custom (pwImageURL, pwHostURL, etc.) + -- Stocker les métadonnées custom (pwImageURL, pwHostURL, pwVideoPreset, etc.) local pluginData = { pwHostURL = propertyTable.host, albumName = albumName, @@ -921,6 +979,7 @@ function PublishTask.processRenderedPhotos(functionContext, exportContext) pwUploadDate = os.date("%Y-%m-%d"), pwUploadTime = os.date("%H:%M:%S"), pwCommentSync = "", + pwVideoPreset = preset, -- 4A : stocker le preset appliqué } PiwigoAPI.storeMetaData(catalog, vPhoto, pluginData) else @@ -937,6 +996,39 @@ function PublishTask.processRenderedPhotos(functionContext, exportContext) progressScope:setPortionComplete(#vtkResults, #vtkResults) end + -- ----------------------------------------------------------------------- + -- Phase 4C — Republication métadonnées seules (vidéos sans re-upload) + -- ----------------------------------------------------------------------- + if metadataOnlyVideos and #metadataOnlyVideos > 0 then + log:info("PublishTask - updating metadata for " .. #metadataOnlyVideos .. " video(s) (metadata-only)") + 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:warn("PublishTask - metadata-only: no image_id for " .. vName .. ", skipping") + else + log:info("PublishTask - metadata-only update for image_id=" .. imageId .. " (" .. vName .. ")") + progressScope:setCaption("Updating metadata: " .. vName) + + local metaData = utils.getPhotoMetadata(propertyTable, vPhoto) + metaData.Albumid = albumId + metaData.Remoteid = imageId + + local updateStatus = PiwigoAPI.updateMetadata(propertyTable, vPhoto, metaData) + if updateStatus.status then + log:info("PublishTask - metadata updated for image_id=" .. imageId) + else + log:warn("PublishTask - metadata update failed for " .. vName + .. ": " .. (updateStatus.statusMsg or "")) + end + end + end + end + progressScope:done() PWStatusManager.setPiwigoBusy(publishService, false) end From e54cd01be06852b9d894753d98b2b1338d1a206a Mon Sep 17 00:00:00 2001 From: Gotcha26 Date: Tue, 17 Feb 2026 14:10:39 +0100 Subject: [PATCH 27/51] Phase 5A + 5B --- .../PublishDialogSections.lua | 25 +++-- piwigoPublish.lrplugin/PublishTask.lua | 51 ++++++++-- piwigoPublish.lrplugin/utils.lua | 95 +++++++++++++++++++ 3 files changed, 153 insertions(+), 18 deletions(-) diff --git a/piwigoPublish.lrplugin/PublishDialogSections.lua b/piwigoPublish.lrplugin/PublishDialogSections.lua index 3ad4756..f14cc00 100644 --- a/piwigoPublish.lrplugin/PublishDialogSections.lua +++ b/piwigoPublish.lrplugin/PublishDialogSections.lua @@ -1145,9 +1145,7 @@ local function videoDialog(f, propertyTable) tooltip = "Run Video Toolkit to verify Python, FFmpeg and ExifTool installations.", action = function(_) LrTasks.startAsyncTask(function() - local python = utils.nilOrEmpty(propertyTable.vtkPythonPath) - and "python" - or propertyTable.vtkPythonPath + local python = utils.resolveTool(propertyTable.vtkPythonPath, "python") local plugin = rawget(_G, "_PLUGIN") local toolkitPath = LrPathUtils.child( LrPathUtils.parent(plugin.path), @@ -1156,12 +1154,23 @@ local function videoDialog(f, propertyTable) local cmd = '"' .. python .. '" "' .. toolkitPath .. '" --mode probe 2>&1' local result = LrTasks.execute(cmd) if result == 0 then - LrDialogs.message("Video Toolkit", - "Video Toolkit found and working.\nPython and ffprobe are available.", - "info") + local detectedNote = "" + if not (propertyTable.vtkPythonPath and propertyTable.vtkPythonPath ~= "") then + detectedNote = "\n\nPython auto-detected at:\n" .. python + .. "\n\nYou can override this in Advanced settings." + end + LrDialogs.message("Video Toolkit — OK", + "Video Toolkit found and working.\nPython and ffprobe are available." + .. detectedNote, "info") else - LrDialogs.message("Video Toolkit — Error", - "Could not run Video Toolkit.\n\nCheck Python and FFmpeg installation, or set explicit paths in Advanced settings.", + local isWindows = (LrSystemInfo.osVersion():lower():find("win") ~= nil) + local installCmd = isWindows + and "winget install Python.Python.3" + or "brew install python" + LrDialogs.message("Video Toolkit — Python Not Found", + "Could not run Video Toolkit.\n\nPython not found at:\n" .. python + .. "\n\nInstall Python:\n " .. installCmd + .. "\n\nOr set an explicit path in Advanced settings.", "critical") end end) diff --git a/piwigoPublish.lrplugin/PublishTask.lua b/piwigoPublish.lrplugin/PublishTask.lua index d63d204..4bda34d 100644 --- a/piwigoPublish.lrplugin/PublishTask.lua +++ b/piwigoPublish.lrplugin/PublishTask.lua @@ -540,10 +540,9 @@ function PublishTask.processRenderedPhotos(functionContext, exportContext) if not videoUploadBlocked and batchVideoCount > 0 and propertyTable.vtkEnabled then log:info("PublishTask - Video Toolkit enabled, processing " .. batchVideoCount .. " video(s)") - -- Résoudre les chemins des outils - local python = (propertyTable.vtkPythonPath and propertyTable.vtkPythonPath ~= "") - and propertyTable.vtkPythonPath - or "python" + -- Résoudre les chemins des outils (config manuelle > auto-détection > fallback PATH) + local python = utils.resolveTool(propertyTable.vtkPythonPath, "python") + log:info("PublishTask - python resolved to: " .. python) local toolkitScript = LrPathUtils.child( LrPathUtils.parent(_PLUGIN.path), "video-toolkit/video_toolkit.py" @@ -620,8 +619,9 @@ function PublishTask.processRenderedPhotos(functionContext, exportContext) progressScope:setCaption("Video Toolkit — Processing " .. batchVideoCount .. " video(s)...") -- Lancer en async + polling du fichier statut - local vtkDone = false - local vtkExitCode = nil + local vtkDone = false + local vtkExitCode = nil + local vtkCancelled = false LrTasks.startAsyncTask(function() vtkExitCode = LrTasks.execute(cmd) @@ -634,6 +634,7 @@ function PublishTask.processRenderedPhotos(functionContext, exportContext) if progressScope:isCanceled() then log:info("PublishTask - VTK polling cancelled by user") + vtkCancelled = true break end @@ -655,7 +656,12 @@ function PublishTask.processRenderedPhotos(functionContext, exportContext) end -- Lire les résultats du toolkit - if vtkExitCode == 0 or vtkExitCode == nil then + if vtkCancelled then + log:info("PublishTask - VTK cancelled by user, videos will be skipped") + LrDialogs.message("Video Toolkit Cancelled", + "Publication cancelled during video processing.\n\nVideos have not been uploaded.", + "warning") + elseif vtkExitCode == 0 or vtkExitCode == nil then -- Lire le statut final local sf = io.open(statusFilePath, "r") if sf then @@ -894,12 +900,16 @@ function PublishTask.processRenderedPhotos(functionContext, exportContext) log:info("PublishTask - uploading " .. #vtkResults .. " video variant(s)") progressScope:setCaption("Uploading video variants...") + 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 - log:warn("PublishTask - skipping video (toolkit error): " .. vName .. " — " .. (vr.error or "")) + local errMsg = vr.error or "Unknown toolkit error" + log:warn("PublishTask - skipping video (toolkit error): " .. vName .. " — " .. errMsg) + table.insert(vtkFailedVideos, "• " .. vName .. "\n " .. errMsg) else log:info("PublishTask - uploading video variant: " .. vr.variantPath) progressScope:setCaption("Uploading video: " .. vName) @@ -993,6 +1003,15 @@ function PublishTask.processRenderedPhotos(functionContext, exportContext) 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 + progressScope:setPortionComplete(#vtkResults, #vtkResults) end @@ -1003,13 +1022,17 @@ function PublishTask.processRenderedPhotos(functionContext, exportContext) log:info("PublishTask - updating metadata for " .. #metadataOnlyVideos .. " video(s) (metadata-only)") progressScope:setCaption("Updating video metadata...") + local metaOnlyFailed = {} + 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 + local errMsg = "No Piwigo image_id found (photo may not have been published yet)" log:warn("PublishTask - metadata-only: no image_id for " .. vName .. ", skipping") + table.insert(metaOnlyFailed, "• " .. vName .. "\n " .. errMsg) else log:info("PublishTask - metadata-only update for image_id=" .. imageId .. " (" .. vName .. ")") progressScope:setCaption("Updating metadata: " .. vName) @@ -1022,11 +1045,19 @@ function PublishTask.processRenderedPhotos(functionContext, exportContext) if updateStatus.status then log:info("PublishTask - metadata updated for image_id=" .. imageId) else - log:warn("PublishTask - metadata update failed for " .. vName - .. ": " .. (updateStatus.statusMsg or "")) + local errMsg = updateStatus.statusMsg or "Unknown error" + log:warn("PublishTask - metadata update failed for " .. vName .. ": " .. errMsg) + table.insert(metaOnlyFailed, "• " .. vName .. "\n " .. errMsg) end end end + + if #metaOnlyFailed > 0 then + LrDialogs.message("Video Metadata Update — Errors", + #metaOnlyFailed .. " video(s) could not have their metadata updated on Piwigo:\n\n" + .. table.concat(metaOnlyFailed, "\n\n"), + "warning") + end end progressScope:done() diff --git a/piwigoPublish.lrplugin/utils.lua b/piwigoPublish.lrplugin/utils.lua index 83bf2cb..9702902 100644 --- a/piwigoPublish.lrplugin/utils.lua +++ b/piwigoPublish.lrplugin/utils.lua @@ -1496,4 +1496,99 @@ function utils.parsePhpSize(sizeStr) 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*$") + 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 + return utils From 7d4e16eb385dee86ecb2ed367c81c90108d89f75 Mon Sep 17 00:00:00 2001 From: Gotcha26 Date: Tue, 17 Feb 2026 14:13:45 +0100 Subject: [PATCH 28/51] Phase 5C --- piwigoPublish.lrplugin/PublishTask.lua | 52 +++++++++++++++++++++++--- 1 file changed, 47 insertions(+), 5 deletions(-) diff --git a/piwigoPublish.lrplugin/PublishTask.lua b/piwigoPublish.lrplugin/PublishTask.lua index 4bda34d..0a5132c 100644 --- a/piwigoPublish.lrplugin/PublishTask.lua +++ b/piwigoPublish.lrplugin/PublishTask.lua @@ -355,8 +355,11 @@ function PublishTask.processRenderedPhotos(functionContext, exportContext) end if existingImageId then appliedPreset = photo:getPropertyForPlugin(_PLUGIN, "pwVideoPreset") or "" - local currentPreset = (propertyTable.vtkDefaultPreset and propertyTable.vtkDefaultPreset ~= "") - and propertyTable.vtkDefaultPreset or "medium" + -- 5C: collection override takes priority over service default + local currentPreset = (collectionSettings.vtkPresetOverride and collectionSettings.vtkPresetOverride ~= "") + and collectionSettings.vtkPresetOverride + or ((propertyTable.vtkDefaultPreset and propertyTable.vtkDefaultPreset ~= "") + and propertyTable.vtkDefaultPreset or "medium") if appliedPreset ~= "" and appliedPreset ~= currentPreset then -- Preset changed → need re-encode (or use cached variant if it exists) republishMode = "re_upload" @@ -547,9 +550,13 @@ function PublishTask.processRenderedPhotos(functionContext, exportContext) LrPathUtils.parent(_PLUGIN.path), "video-toolkit/video_toolkit.py" ) - local preset = (propertyTable.vtkDefaultPreset and propertyTable.vtkDefaultPreset ~= "") - and propertyTable.vtkDefaultPreset - or "medium" + -- 5C: collection override takes priority over service default + local preset = (collectionSettings.vtkPresetOverride and collectionSettings.vtkPresetOverride ~= "") + and collectionSettings.vtkPresetOverride + or ((propertyTable.vtkDefaultPreset and propertyTable.vtkDefaultPreset ~= "") + and propertyTable.vtkDefaultPreset or "medium") + log:info("PublishTask - video preset effective: " .. preset + .. (collectionSettings.vtkPresetOverride ~= "" and " (collection override)" or " (service default)")) -- Fichier statut global pour le polling local statusFilePath = LrPathUtils.child( @@ -1545,6 +1552,7 @@ local function initCollectionSettingsDefaults(collectionSettings) KwFilterInclude = "", KwFilterExclude = "", syncSortOrderOverride = "default", + vtkPresetOverride = "", -- 5C: "" = use service default } for key, defaultVal in pairs(defaults) do if collectionSettings[key] == nil then @@ -1696,11 +1704,45 @@ function PublishTask.viewForCollectionSettings(f, publishSettings, info) }, } + -- 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 From c2c20b2e1f96b6a4304d73f3298339317fcce088 Mon Sep 17 00:00:00 2001 From: Gotcha26 Date: Tue, 17 Feb 2026 14:21:47 +0100 Subject: [PATCH 29/51] Phase 5D --- VALIDATION-CHECKLIST.md | 111 ++++++++++ video-toolkit/src/presets.py | 2 +- video-toolkit/tests/test_hasher.py | 106 ++++++++++ video-toolkit/tests/test_presets.py | 300 ++++++++++++++++++++++++++++ video-toolkit/tests/test_status.py | 280 ++++++++++++++++++++++++++ 5 files changed, 798 insertions(+), 1 deletion(-) create mode 100644 VALIDATION-CHECKLIST.md create mode 100644 video-toolkit/tests/test_hasher.py create mode 100644 video-toolkit/tests/test_presets.py create mode 100644 video-toolkit/tests/test_status.py diff --git a/VALIDATION-CHECKLIST.md b/VALIDATION-CHECKLIST.md new file mode 100644 index 0000000..7753a3e --- /dev/null +++ b/VALIDATION-CHECKLIST.md @@ -0,0 +1,111 @@ +# PiwigoPublish — Checklist de validation manuelle (Phase 5D) + +> Validation à effectuer dans Lightroom Classic avec un service Piwigo de test. +> Cocher chaque cas avant de considérer une release stable. + +--- + +## Prérequis + +- [ ] Piwigo installé et accessible (serveur local ou distant) +- [ ] Plugin `lightroom-companion` installé et activé sur Piwigo +- [ ] Video Toolkit installé (`python video_toolkit.py --mode probe` retourne du JSON) +- [ ] FFmpeg disponible (auto-détecté ou configuré) +- [ ] Au moins 3 photos JPG de test + 2 vidéos MP4 de test dans le catalogue LrC + +--- + +## 1. Configuration du service + +| # | Scénario | Résultat attendu | OK | +|---|----------|-----------------|-----| +| 1.1 | Configurer le service avec URL/user/pass valides | Connexion verte dans le panneau | ☐ | +| 1.2 | Activer "Enable Video Toolkit" | Sections preset et outils apparaissent | ☐ | +| 1.3 | Laisser les chemins outils vides → cliquer "Check Tools" | Détection auto + dialog "auto-detected at: ..." | ☐ | +| 1.4 | Saisir un chemin Python invalide → cliquer "Check Tools" | Dialog d'erreur avec commande d'installation | ☐ | +| 1.5 | Sélectionner preset "Medium (720p)" comme défaut service | Valeur persistée à la réouverture du manager | ☐ | + +--- + +## 2. Publication de photos seules (régression) + +| # | Scénario | Résultat attendu | OK | +|---|----------|-----------------|-----| +| 2.1 | Publier 3 photos JPG dans un album | Photos visibles dans Piwigo, marquées "Published" dans LrC | ☐ | +| 2.2 | Re-publier une photo modifiée (métadonnées) | Mise à jour titre/description sur Piwigo sans erreur | ☐ | +| 2.3 | Supprimer une photo publiée dans LrC | Photo supprimée de Piwigo | ☐ | +| 2.4 | Publier avec filtre de mots-clés actif | Seules les photos correspondant au filtre sont publiées | ☐ | + +--- + +## 3. Publication de vidéos — premier envoi + +| # | Scénario | Résultat attendu | OK | +|---|----------|-----------------|-----| +| 3.1 | Publier 1 vidéo MP4 (preset Medium) | Variante 720p visible dans Piwigo, poster affiché, vidéo marquée "Published" | ☐ | +| 3.2 | Vérifier les métadonnées custom dans LrC | `pwVideoPreset = "medium"`, `pwImageURL` rempli, `pwUploadDate` correct | ☐ | +| 3.3 | Publier 2 vidéos en même batch | Les deux uploadées, progression affichée pour chaque | ☐ | +| 3.4 | Publier batch mixte 2 photos + 1 vidéo | Photos et vidéo toutes publiées, types traités séparément | ☐ | +| 3.5 | Vérifier le poster dans Piwigo | Miniature personnalisée visible (pas l'icône générique vidéo) | ☐ | + +--- + +## 4. Publication de vidéos — republication + +| # | Scénario | Résultat attendu | OK | +|---|----------|-----------------|-----| +| 4.1 | Re-publier une vidéo sans changement (même preset) | Metadata-only : pas de re-upload, titre/description mis à jour | ☐ | +| 4.2 | Changer le preset service (Medium → Large) puis republier | Re-encode forcé, nouvelle variante 1080p uploadée | ☐ | +| 4.3 | Vérifier que `pwVideoPreset` est mis à jour après 4.2 | Valeur = "large" dans les métadonnées custom LrC | ☐ | +| 4.4 | Re-publier sans changement après 4.2 | Metadata-only (pas de re-encode), log confirme | ☐ | + +--- + +## 5. Override preset par collection (5C) + +| # | Scénario | Résultat attendu | OK | +|---|----------|-----------------|-----| +| 5.1 | Ouvrir les settings d'un album → section "Video Preset Override" visible | Popup "Use service default" + 6 presets | ☐ | +| 5.2 | Sélectionner "Small (480p)" comme override → publier une vidéo | Variante 480p créée et uploadée (pas 720p) | ☐ | +| 5.3 | Vérifier `pwVideoPreset = "small"` dans les métadonnées | Valeur correcte stockée | ☐ | +| 5.4 | Revenir à "Use service default" → republier | Re-encode avec le preset service (Medium), pas Small | ☐ | +| 5.5 | Deux albums avec presets différents, publier une vidéo dans chacun | Chaque album utilise son preset respectif | ☐ | + +--- + +## 6. Cas d'erreur et gestion (5A) + +| # | Scénario | Résultat attendu | OK | +|---|----------|-----------------|-----| +| 6.1 | Publier vidéo avec plugin Companion désactivé sur Piwigo | Dialog "Companion plugin not installed", vidéos retirées du batch, photos publiées | ☐ | +| 6.2 | Publier vidéo avec Video Toolkit désactivé dans les settings | Vidéos ignorées silencieusement (ou message), photos publiées | ☐ | +| 6.3 | Annuler pendant le traitement toolkit (barre de progression) | Dialog "Publication cancelled during video processing" | ☐ | +| 6.4 | Simuler échec toolkit (Python introuvable après config) | Dialog "Video Toolkit Error (exit code X)" | ☐ | +| 6.5 | Publier vidéo metadata-only avec image_id manquant | Dialog warning avec nom du fichier concerné | ☐ | + +--- + +## 7. Upload chunked (vidéos volumineuses) + +| # | Scénario | Résultat attendu | OK | +|---|----------|-----------------|-----| +| 7.1 | Configurer server max = 50 MB, publier vidéo de 80 MB | Upload chunked déclenché (log "→ chunked upload"), vidéo visible sur Piwigo | ☐ | +| 7.2 | Publier vidéo sous la limite | Upload standard addSimple (log "→ addSimple upload") | ☐ | + +--- + +## 8. Auto-détection outils (5B) + +| # | Scénario | Résultat attendu | OK | +|---|----------|-----------------|-----| +| 8.1 | Vider le champ Python + cliquer "Check Tools" | Python auto-détecté, chemin affiché dans le dialog | ☐ | +| 8.2 | Publier une vidéo sans aucun chemin configuré | Python auto-détecté et utilisé, publication réussie | ☐ | +| 8.3 | Vérifier le log plugin (`LrC/Plug-in Log`) | Ligne "python resolved to: C:/..." présente | ☐ | + +--- + +## Notes de test + +- Logs plugin LrC : **Aide → Plug-in Log** → chercher `PublishTask` +- Dossier `.vtk/` créé à côté des vidéos originales (variantes + cache hash) +- En cas d'échec inattendu, joindre le log complet au rapport de bug diff --git a/video-toolkit/src/presets.py b/video-toolkit/src/presets.py index ae68394..3dcf79f 100644 --- a/video-toolkit/src/presets.py +++ b/video-toolkit/src/presets.py @@ -48,7 +48,7 @@ def to_dict(self) -> dict: @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__} + d = {k: v for k, v in d.items() if k in cls.__dataclass_fields__ and k != "name"} return cls(name=name, **d) diff --git a/video-toolkit/tests/test_hasher.py b/video-toolkit/tests/test_hasher.py new file mode 100644 index 0000000..06b16a4 --- /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): + with pytest.raises((FileNotFoundError, OSError)): + partial_hash(Path("/nonexistent/file.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() From 559e72562606fb2c930b8d7fe57211f6ea029543 Mon Sep 17 00:00:00 2001 From: Gotcha26 Date: Tue, 17 Feb 2026 21:51:15 +0100 Subject: [PATCH 30/51] Preparations for tests --- .../PublishDialogSections.lua | 142 ++++++++++++++---- .../PublishServiceProvider.lua | 8 +- piwigoPublish.lrplugin/PublishTask.lua | 10 +- piwigoPublish.lrplugin/utils.lua | 18 ++- video-toolkit/src/cli.py | 3 +- video-toolkit/src/config.py | 10 ++ video-toolkit/src/processor.py | 6 +- video-toolkit/video_toolkit.py | 39 +++++ 8 files changed, 190 insertions(+), 46 deletions(-) diff --git a/piwigoPublish.lrplugin/PublishDialogSections.lua b/piwigoPublish.lrplugin/PublishDialogSections.lua index f14cc00..5e786ff 100644 --- a/piwigoPublish.lrplugin/PublishDialogSections.lua +++ b/piwigoPublish.lrplugin/PublishDialogSections.lua @@ -964,26 +964,39 @@ local function videoDialog(f, propertyTable) f:group_box { title = "Video Toolkit", - font = "", + font = "", fill_horizontal = 1, f:spacer { height = 2 }, -- Enable/disable toggle 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.", }, }, + f:row { + fill_horizontal = 1, + f:checkbox { + title = "Include video files", + fill_horizontal = 1, + value = bind "vtkIncludeVideo", + tooltip = "Include video files in publications. Requires Video Toolkit to be enabled.", + enabled = bind "vtkEnabled", + }, + }, + f:spacer { height = 4 }, -- Preset + poster settings (enabled only when vtkEnabled = true) - f:group_box { - title = "Encoding Settings", - font = "", + f:separator { fill_horizontal = 1 }, + f:row { f:static_text { title = "Encoding Settings", fill_horizontal = 1 } }, + f:column { fill_horizontal = 1, enabled = bind "vtkEnabled", @@ -1012,6 +1025,7 @@ local function videoDialog(f, propertyTable) }, f:checkbox { title = "Generate poster (JPG)", + fill_horizontal = 1, value = bind "vtkGeneratePoster", tooltip = "Extract a JPG thumbnail from the video and upload as representative image.", }, @@ -1039,14 +1053,28 @@ local function videoDialog(f, propertyTable) f:spacer { height = 4 }, -- Advanced paths (collapsible group_box) - f:group_box { - title = "Advanced — Tool Paths", - font = "", + f:separator { fill_horizontal = 1 }, + f:row { f:static_text { title = "Advanced — Tool Paths", fill_horizontal = 1 } }, + f:column { fill_horizontal = 1, enabled = bind "vtkEnabled", f:spacer { height = 2 }, + f:row { + f:static_text { + title = "Toolkit:", + alignment = 'right', + width = share 'vtk_label_w', + }, + f:edit_field { + value = bind "vtkToolkitPath", + placeholder_string = "(auto: /video-toolkit/video_toolkit.py)", + fill_horizontal = 1, + tooltip = "Path to video_toolkit.py. Leave empty to use the bundled toolkit next to the plugin.", + }, + }, + f:row { f:static_text { title = "Python:", @@ -1075,6 +1103,20 @@ local function videoDialog(f, propertyTable) }, }, + 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).", + placeholder_string = "(auto-detect)", + }, + }, + f:row { f:static_text { title = "ExifTool:", @@ -1109,9 +1151,9 @@ local function videoDialog(f, propertyTable) f:spacer { height = 4 }, -- Status + action buttons - f:group_box { - title = "Status", - font = "", + f:separator { fill_horizontal = 1 }, + f:row { f:static_text { title = "Status", fill_horizontal = 1 } }, + f:column { fill_horizontal = 1, enabled = bind "vtkEnabled", @@ -1145,35 +1187,71 @@ local function videoDialog(f, propertyTable) 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 = LrPathUtils.child( - LrPathUtils.parent(plugin.path), - "video-toolkit/video_toolkit.py" - ) - local cmd = '"' .. python .. '" "' .. toolkitPath .. '" --mode probe 2>&1' + 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 detectedNote = "" + 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 - detectedNote = "\n\nPython auto-detected at:\n" .. python - .. "\n\nYou can override this in Advanced settings." + propertyTable.vtkPythonPath = python + end + if not (propertyTable.vtkToolkitPath and propertyTable.vtkToolkitPath ~= "") then + propertyTable.vtkToolkitPath = toolkitPath end LrDialogs.message("Video Toolkit — OK", - "Video Toolkit found and working.\nPython and ffprobe are available." - .. detectedNote, "info") + "All tools verified and working.\n\nDetected paths have been filled in Advanced settings.", "info") else - local isWindows = (LrSystemInfo.osVersion():lower():find("win") ~= nil) - local installCmd = isWindows - and "winget install Python.Python.3" - or "brew install python" - LrDialogs.message("Video Toolkit — Python Not Found", - "Could not run Video Toolkit.\n\nPython not found at:\n" .. python - .. "\n\nInstall Python:\n " .. installCmd - .. "\n\nOr set an explicit path in Advanced settings.", - "critical") + 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) end, }, f:push_button { diff --git a/piwigoPublish.lrplugin/PublishServiceProvider.lua b/piwigoPublish.lrplugin/PublishServiceProvider.lua index 7caa9cd..2ae8093 100644 --- a/piwigoPublish.lrplugin/PublishServiceProvider.lua +++ b/piwigoPublish.lrplugin/PublishServiceProvider.lua @@ -33,7 +33,7 @@ return { sectionsForBottomOfDialog = PublishDialogSections.sectionsForBottomOfDialog, endDialog = PublishDialogSections.endDialog, - hideSections = { 'exportLocation' }, + hideSections = { 'exportLocation', 'videoFileSettings' }, -- Behaviour Settings @@ -41,8 +41,7 @@ return { allowColorSpaces = nil, canExportVideo = true, allowVideoExportPresets = { - { formatID = "h.264" }, - { formatID = "original" }, + { formatID = "original" }, -- LrC ne ré-encode pas ; Video Toolkit gère le transcodage }, supportsCustomSortOrder = true, hidePrintResolution = true, @@ -67,11 +66,14 @@ return { { 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 = '' }, }, diff --git a/piwigoPublish.lrplugin/PublishTask.lua b/piwigoPublish.lrplugin/PublishTask.lua index 0a5132c..58b5d09 100644 --- a/piwigoPublish.lrplugin/PublishTask.lua +++ b/piwigoPublish.lrplugin/PublishTask.lua @@ -385,8 +385,8 @@ function PublishTask.processRenderedPhotos(functionContext, exportContext) log:info("PublishTask - pre-scan: " .. batchVideoCount .. " video(s) detected in batch of " .. batchTotalCount) -- Check if user disabled video inclusion in publish settings - if propertyTable.LR_includeVideoFiles == false then - log:info("PublishTask - video inclusion disabled by user (LR_includeVideoFiles = false)") + if propertyTable.vtkIncludeVideo == false then + log:info("PublishTask - video inclusion disabled by user (vtkIncludeVideo = false)") videoUploadBlocked = true for _, vEntry in ipairs(videoPhotos) do local vName = vEntry.photo:getFormattedMetadata("fileName") or "unknown" @@ -546,10 +546,8 @@ function PublishTask.processRenderedPhotos(functionContext, exportContext) -- Résoudre les chemins des outils (config manuelle > auto-détection > fallback PATH) local python = utils.resolveTool(propertyTable.vtkPythonPath, "python") log:info("PublishTask - python resolved to: " .. python) - local toolkitScript = LrPathUtils.child( - LrPathUtils.parent(_PLUGIN.path), - "video-toolkit/video_toolkit.py" - ) + local toolkitScript = utils.resolveToolkitPath(propertyTable.vtkToolkitPath, _PLUGIN.path) + log:info("PublishTask - toolkitScript resolved to: " .. toolkitScript) -- 5C: collection override takes priority over service default local preset = (collectionSettings.vtkPresetOverride and collectionSettings.vtkPresetOverride ~= "") and collectionSettings.vtkPresetOverride diff --git a/piwigoPublish.lrplugin/utils.lua b/piwigoPublish.lrplugin/utils.lua index 9702902..9af8b0a 100644 --- a/piwigoPublish.lrplugin/utils.lua +++ b/piwigoPublish.lrplugin/utils.lua @@ -1510,7 +1510,8 @@ function utils.findTool(toolName) local found = pHandle:read("*l") pHandle:close() if found and found ~= "" then - found = found:match("^(.-)%s*$") + found = found:match("^(.-)%s*$") -- trim trailing whitespace + found = found:gsub("\r", "") -- strip CR (Windows CRLF via io.popen) if found ~= "" then return found end @@ -1591,4 +1592,19 @@ function utils.resolveTool(configuredPath, toolName) 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/video-toolkit/src/cli.py b/video-toolkit/src/cli.py index 29db33c..6129f48 100644 --- a/video-toolkit/src/cli.py +++ b/video-toolkit/src/cli.py @@ -41,7 +41,7 @@ def build_parser() -> argparse.ArgumentParser: ) p.add_argument( "--mode", - choices=["probe", "process", "batch", "status", "clean"], + 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") @@ -53,6 +53,7 @@ def build_parser() -> argparse.ArgumentParser: 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("--verbose", action="store_true", help="Sortie détaillée") p.add_argument("--dry-run", action="store_true", dest="dry_run", help="Simuler sans écrire") return p diff --git a/video-toolkit/src/config.py b/video-toolkit/src/config.py index 8b07ae2..6247d71 100644 --- a/video-toolkit/src/config.py +++ b/video-toolkit/src/config.py @@ -122,13 +122,23 @@ def _windows_candidates(tool: str) -> list[str]: 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", diff --git a/video-toolkit/src/processor.py b/video-toolkit/src/processor.py index 62aa289..5ac3f1a 100644 --- a/video-toolkit/src/processor.py +++ b/video-toolkit/src/processor.py @@ -23,7 +23,7 @@ from .ffmpeg import FFmpeg, FFmpegError from .ffprobe import FFprobe, ProbeError, VideoInfo -from .hasher import Hasher +from .hasher import partial_hash from .metadata import ExifTool from .presets import PresetManager, VideoPreset from .status import StatusManager, STATE_PROCESSING, STATE_COMPLETE, STATE_ERROR @@ -72,7 +72,7 @@ def __init__( self._ffmpeg = FFmpeg(ffmpeg_path) self._ffprobe = FFprobe(ffprobe_path) self._exiftool = ExifTool(exiftool_path) - self._hasher = Hasher() + self._presets = preset_manager or PresetManager() self._thumb_pct = thumbnail_timestamp_pct self._thumb_max_w = thumbnail_max_width @@ -117,7 +117,7 @@ def process( return self._error_result(str(input_path), preset_key, str(e)) # --- 3. Hash source --- - src_hash = self._hasher.hash_file(input_path) + src_hash = partial_hash(input_path) # --- 4. StatusManager --- status = StatusManager(input_path) diff --git a/video-toolkit/video_toolkit.py b/video-toolkit/video_toolkit.py index 05491cc..fbb7e59 100644 --- a/video-toolkit/video_toolkit.py +++ b/video-toolkit/video_toolkit.py @@ -34,6 +34,45 @@ def main() -> int: # 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" + + 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"], + })) + return 0 if ok else 1 if args.mode == "probe": return run_probe(args, cfg) if args.mode == "process": From 8fb6834c4f34d966f6e0f6e4158ee1d652f2fa0d Mon Sep 17 00:00:00 2001 From: Gotcha26 Date: Wed, 18 Feb 2026 00:03:41 +0100 Subject: [PATCH 31/51] Major corrections --- lightroom-companion/main.inc.php | 9 +- piwigoPublish.lrplugin/PiwigoAPI.lua | 53 ++- piwigoPublish.lrplugin/PublishTask.lua | 500 +++++++++++++------------ video-toolkit/src/cli.py | 3 + video-toolkit/video_toolkit.py | 17 + 5 files changed, 319 insertions(+), 263 deletions(-) diff --git a/lightroom-companion/main.inc.php b/lightroom-companion/main.inc.php index a430b79..a54b07f 100644 --- a/lightroom-companion/main.inc.php +++ b/lightroom-companion/main.inc.php @@ -295,10 +295,15 @@ function companion_set_representative($params, &$service) return new PwgError(WS_ERR_INVALID_PARAM, 'Poster must be jpg, jpeg, png or webp'); } - // Piwigo representative: same filename as image but with new extension + // Piwigo representative: stored in pwg_representative/ subdirectory $image_basename = pathinfo($row['path'], PATHINFO_FILENAME); $representative_filename = $image_basename . '.' . $uploaded_ext; - $representative_path = $image_dir . '/' . $representative_filename; + $representative_dir = $image_dir . '/pwg_representative'; + if (!is_dir($representative_dir)) + { + @mkdir($representative_dir, 0755, true); + } + $representative_path = $representative_dir . '/' . $representative_filename; if (!move_uploaded_file($_FILES['file']['tmp_name'], $representative_path)) { diff --git a/piwigoPublish.lrplugin/PiwigoAPI.lua b/piwigoPublish.lrplugin/PiwigoAPI.lua index 0f44256..f8c7e5a 100644 --- a/piwigoPublish.lrplugin/PiwigoAPI.lua +++ b/piwigoPublish.lrplugin/PiwigoAPI.lua @@ -31,6 +31,18 @@ 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 @@ -70,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) @@ -111,17 +124,13 @@ 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) + local rtnBody = JSON:decode(stripPhpWarnings(httpResponse)) if rtnBody and rtnBody.stat == "ok" then -- login ok - store session cookies local cookies = {} @@ -1311,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 @@ -1343,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 @@ -1445,7 +1448,6 @@ function PiwigoAPI.getInfos(propertyTable) rtnStatus.result = apiResult end end - log:info("PiwigoAPI.getInfos - result keys: " .. utils.serialiseVar(apiResult)) else rtnStatus.message = "Cannot get host information from Piwigo - " .. ((getResponse.status .. " - " .. (getResponse.errorMessage or "Unknown error")) or "Unknown error") @@ -1492,8 +1494,6 @@ function PiwigoAPI.getServerVideoSupport(propertyTable) 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 @@ -1865,7 +1865,7 @@ 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 and parseResp.stat == "ok" then @@ -2036,7 +2036,7 @@ 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 and parseResp.stat == "ok" then @@ -2116,7 +2116,7 @@ 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 and body.stat == "ok" then @@ -2393,7 +2393,7 @@ 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)) @@ -2659,7 +2659,7 @@ 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 @@ -2903,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(publishSettings)) if not publishSettings then LrDialogs.message("PiwigoAPI.setAlbumCover - Can't find PublishSettings for this publish collection", "", @@ -3152,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 @@ -3388,7 +3387,7 @@ function PiwigoAPI.uploadVideoChunked(propertyTable, filePath, metaData, chunkSi return callStatus end - local ok, body = pcall(function() return JSON:decode(httpResponse) 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) diff --git a/piwigoPublish.lrplugin/PublishTask.lua b/piwigoPublish.lrplugin/PublishTask.lua index 58b5d09..acfbeb5 100644 --- a/piwigoPublish.lrplugin/PublishTask.lua +++ b/piwigoPublish.lrplugin/PublishTask.lua @@ -354,20 +354,30 @@ function PublishTask.processRenderedPhotos(functionContext, exportContext) existingImageId = utils.extractPwImageIdFromUrl(storedUrl, propertyTable.host) end if existingImageId then - appliedPreset = photo:getPropertyForPlugin(_PLUGIN, "pwVideoPreset") or "" - -- 5C: collection override takes priority over service default - local currentPreset = (collectionSettings.vtkPresetOverride and collectionSettings.vtkPresetOverride ~= "") - and collectionSettings.vtkPresetOverride - or ((propertyTable.vtkDefaultPreset and propertyTable.vtkDefaultPreset ~= "") - and propertyTable.vtkDefaultPreset or "medium") - if appliedPreset ~= "" and appliedPreset ~= currentPreset then - -- Preset changed → need re-encode (or use cached variant if it exists) - republishMode = "re_upload" + -- Verify image still exists on Piwigo + local checkStatus = PiwigoAPI.checkPhoto(propertyTable, existingImageId) + if not checkStatus.status then + -- Image was deleted from Piwigo → treat as new upload + log:info("PublishTask - video image_id=" .. existingImageId .. " no longer exists on Piwigo, treating as new") + existingImageId = nil + republishMode = "new" else - -- Same preset (or no preset recorded) → check if source file changed via .vtk cache - -- If hash matches → metadata only; if not → re_upload - -- Default to metadata_only; the toolkit will detect hash mismatch and re-encode - republishMode = "metadata_only" + appliedPreset = photo:getPropertyForPlugin(_PLUGIN, "pwVideoPreset") or "" + -- 5C: collection override takes priority over service default + local currentPreset = (collectionSettings.vtkPresetOverride and collectionSettings.vtkPresetOverride ~= "") + and collectionSettings.vtkPresetOverride + or ((propertyTable.vtkDefaultPreset and propertyTable.vtkDefaultPreset ~= "") + and propertyTable.vtkDefaultPreset or "medium") + if appliedPreset == "" then + -- No preset recorded → video was never processed by VTK → need full processing + republishMode = "re_upload" + elseif appliedPreset ~= currentPreset then + -- Preset changed → need re-encode + republishMode = "re_upload" + else + -- Same preset → metadata only + republishMode = "metadata_only" + end end end @@ -491,7 +501,8 @@ function PublishTask.processRenderedPhotos(functionContext, exportContext) end else -- Server allows video — check per-file size BEFORE rendering (only for new/re_upload) - if serverMaxBytes then + -- Skip size check if VTK is enabled: the variant produced by VTK will be uploaded, not the original + if serverMaxBytes and not propertyTable.vtkEnabled then local oversizedVideos = {} for idx = #videoPhotos, 1, -1 do local vEntry = videoPhotos[idx] @@ -539,6 +550,7 @@ function PublishTask.processRenderedPhotos(functionContext, exportContext) -- vtkResults[i] = { photo, existingImageId, republishMode, variantPath, thumbnailPath, status, error } local vtkResults = {} local metadataOnlyVideos = {} -- 4C : videos needing metadata-only update + local vtkUploadedByLocalId = {} -- index: localIdentifier → { imageId, remoteUrl } if not videoUploadBlocked and batchVideoCount > 0 and propertyTable.vtkEnabled then log:info("PublishTask - Video Toolkit enabled, processing " .. batchVideoCount .. " video(s)") @@ -611,14 +623,23 @@ function PublishTask.processRenderedPhotos(functionContext, exportContext) local presetsArg = (propertyTable.vtkPresetsFile and propertyTable.vtkPresetsFile ~= "") and (' --config "' .. propertyTable.vtkPresetsFile .. '"') or "" - -- Commande complète (bloquante — le toolkit s'arrête quand tout est traité) + -- Commande complète — stdout+stderr capturés via --log-file (Python) + local vtkLogPath = LrPathUtils.child(LrPathUtils.getStandardFilePath("temp"), "piwigoPublish_vtk.log") local cmd = '"' .. python .. '" "' .. toolkitScript .. '"' .. ' --mode batch' .. ' --batch-file "' .. batchFilePath .. '"' .. ' --status-file "' .. statusFilePath .. '"' + .. ' --log-file "' .. vtkLogPath .. '"' .. ffmpegArg .. exiftoolArg .. presetsArg log:info("PublishTask - VTK command: " .. cmd) + log:info("PublishTask - VTK log file: " .. vtkLogPath) + -- Log du contenu du batch file pour diagnostic + local bfDiag = io.open(batchFilePath, "r") + if bfDiag then + log:info("PublishTask - VTK batch content: " .. (bfDiag:read("*all") or "")) + bfDiag:close() + end -- Configurer la progression LrC pendant le traitement vidéo progressScope:setCaption("Video Toolkit — Processing " .. batchVideoCount .. " video(s)...") @@ -630,6 +651,7 @@ function PublishTask.processRenderedPhotos(functionContext, exportContext) LrTasks.startAsyncTask(function() vtkExitCode = LrTasks.execute(cmd) + log:info("PublishTask - LrTasks.execute returned: " .. tostring(vtkExitCode) .. " (type=" .. type(vtkExitCode) .. ")") vtkDone = true end) @@ -660,68 +682,196 @@ function PublishTask.processRenderedPhotos(functionContext, exportContext) end end - -- Lire les résultats du toolkit + -- Lire les résultats depuis le log file (source de vérité — indépendant du code de sortie) + -- Note: LrTasks.execute peut retourner 1 même si Python sort en 0 (bug Windows/Python 3.14) if vtkCancelled then log:info("PublishTask - VTK cancelled by user, videos will be skipped") LrDialogs.message("Video Toolkit Cancelled", "Publication cancelled during video processing.\n\nVideos have not been uploaded.", "warning") - elseif vtkExitCode == 0 or vtkExitCode == nil then - -- Lire le statut final - local sf = io.open(statusFilePath, "r") - if sf then - local content = sf:read("*all") - sf:close() - local ok, statusData = pcall(function() return JSON:decode(content) end) - if ok and statusData and statusData.results then - -- Construire vtkResults indexé par chemin source - local resultsByPath = {} - for _, r in ipairs(statusData.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 "", - 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 + else + if vtkExitCode ~= 0 and vtkExitCode ~= nil then + log:warn("PublishTask - VTK exit code: " .. tostring(vtkExitCode) .. " — checking log file for actual status") + end + -- Lire le JSON depuis le log file (contient les résultats complets) + local vtkOutput = nil + local lf = io.open(vtkLogPath, "r") + if lf then + local raw = lf:read("*all") or "" + lf:close() + local ok, parsed = pcall(function() return JSON:decode(raw) end) + if ok and parsed then + vtkOutput = parsed + else + log:warn("PublishTask - VTK log parse failed: " .. raw:sub(1, 500)) + end + else + log:warn("PublishTask - VTK log not found: " .. vtkLogPath) + end + + if vtkOutput and vtkOutput.status == "ok" and vtkOutput.results then + -- Succès — construire vtkResults indexé par chemin source + 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 "", + 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 + -- Échec réel : pas de log, JSON invalide, ou status != "ok" + local reason = (vtkOutput and vtkOutput.status) or "no output" + log:warn("PublishTask - VTK failed: " .. reason) + LrDialogs.message("Video Toolkit Error", + "Video Toolkit failed.\n\nDetails in:\n" .. vtkLogPath + .. "\n\nVideos will be skipped.", + "critical") end - else - log:warn("PublishTask - VTK exited with code: " .. tostring(vtkExitCode)) - LrDialogs.message("Video Toolkit Error", - "Video Toolkit failed (exit code " .. tostring(vtkExitCode) .. ").\n\n" - .. "Videos will be skipped. Check Video Toolkit settings.", - "critical") end progressScope:setCaption("Publishing to Piwigo...") progressScope:setPortionComplete(0, 100) end -- if #batchVideos == 0 / else + + -- ----------------------------------------------------------------------- + -- Upload des variantes VTK AVANT la boucle renditions + -- (pour pouvoir appeler recordPublishedPhotoId dans la boucle) + -- ----------------------------------------------------------------------- + if #vtkResults > 0 then + log:info("PublishTask - uploading " .. #vtkResults .. " video variant(s)") + progressScope:setCaption("Uploading video variants...") + + 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("PublishTask - skipping video (toolkit error): " .. vName .. " — " .. errMsg) + table.insert(vtkFailedVideos, "• " .. vName .. "\n " .. errMsg) + else + log:info("PublishTask - uploading video 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( + "PublishTask - video %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("PublishTask - video " .. vName .. " → addSimple upload") + uploadStatus = PiwigoAPI.updateGallery(propertyTable, vr.variantPath, metaData) + end + + if uploadStatus.status then + local imageId = uploadStatus.remoteid or "" + log:info("PublishTask - video variant uploaded, image_id=" .. imageId) + + -- Upload du poster + if vr.thumbnailPath and vr.thumbnailPath ~= "" + and LrFileUtils.exists(vr.thumbnailPath) then + if companionAvailable then + log:info("PublishTask - uploading poster: " .. vr.thumbnailPath) + progressScope:setCaption("Uploading poster: " .. vName) + local posterStatus = PiwigoAPI.setRepresentative( + propertyTable, imageId, vr.thumbnailPath) + if posterStatus.status then + log:info("PublishTask - poster set for image_id=" .. imageId) + else + log:warn("PublishTask - poster upload failed: " + .. (posterStatus.statusMsg or "")) + end + end + end + + -- Mettre à jour les métadonnées sur Piwigo + metaData.Remoteid = imageId + PiwigoAPI.updateMetadata(propertyTable, vPhoto, metaData) + + -- Stocker le résultat pour la boucle renditions + vr.uploadedImageId = imageId + vr.uploadedRemoteUrl = uploadStatus.remoteurl or "" + + -- Stocker les métadonnées custom + 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) + else + log:warn("PublishTask - video variant 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 + + -- Construire l'index des résultats VTK par localIdentifier (pour la boucle renditions) + for _, vr in ipairs(vtkResults) do + if vr.uploadedImageId then + vtkUploadedByLocalId[vr.photo.localIdentifier] = { + imageId = vr.uploadedImageId, + remoteUrl = vr.uploadedRemoteUrl or "", + } + end + end end + 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 @@ -746,16 +896,65 @@ function PublishTask.processRenderedPhotos(functionContext, exportContext) local fileFormat = lrPhoto:getRawMetadata("fileFormat") local isVideo = (fileFormat == "VIDEO") - -- Video: blocked/oversized videos already removed before the loop via removePhoto - local videoBlocked = false + -- Video VTK: already uploaded before the loop, just consume rendition and record ID + local videoHandled = false if isVideo then - log:info("PublishTask.processRenderedPhotos - video detected: " - .. (lrPhoto:getFormattedMetadata("fileName") or "unknown")) + local vtkResult = vtkUploadedByLocalId[lrPhoto.localIdentifier] + if vtkResult then + -- Video was processed and uploaded by VTK — consume render and mark published + local vName = lrPhoto:getFormattedMetadata("fileName") or "unknown" + log:info("PublishTask - VTK video in renditions loop: " .. vName .. " — marking published (image_id=" .. vtkResult.imageId .. ")") + local success, pathOrMessage = rendition:waitForRender() + if success and LrFileUtils.exists(pathOrMessage) then + LrFileUtils.delete(pathOrMessage) + end + rendition:recordPublishedPhotoId(vtkResult.imageId) + rendition:recordPublishedPhotoUrl(vtkResult.remoteUrl) + rendition:renditionIsDone(true) + videoHandled = true + end + if not videoHandled then + -- metadata-only videos: consume render, update metadata, mark published + local metaOnlyEntry = nil + for _, vEntry in ipairs(metadataOnlyVideos) do + if vEntry.photo.localIdentifier == lrPhoto.localIdentifier then + metaOnlyEntry = vEntry + break + end + end + if metaOnlyEntry then + local vName = lrPhoto:getFormattedMetadata("fileName") or "unknown" + local imageId = metaOnlyEntry.existingImageId or "" + log:info("PublishTask - metadata-only video in renditions loop: " .. vName .. " (image_id=" .. imageId .. ")") + local success, pathOrMessage = rendition:waitForRender() + if success and LrFileUtils.exists(pathOrMessage) then + LrFileUtils.delete(pathOrMessage) + end + if imageId ~= "" then + local metaData = utils.getPhotoMetadata(propertyTable, lrPhoto) + metaData.Albumid = albumId + metaData.Remoteid = imageId + PiwigoAPI.updateMetadata(propertyTable, lrPhoto, metaData) + log:info("PublishTask - metadata updated for image_id=" .. imageId) + rendition:recordPublishedPhotoId(imageId) + rendition:recordPublishedPhotoUrl(tostring(rendition.publishedPhotoId or "")) + rendition:renditionIsDone(true) + else + log:warn("PublishTask - metadata-only: no image_id for " .. vName) + rendition:uploadFailed("No Piwigo image_id found") + end + videoHandled = true + end + end + if not videoHandled then + log:info("PublishTask.processRenderedPhotos - video detected: " + .. (lrPhoto:getFormattedMetadata("fileName") or "unknown")) + end end - -- Keyword filter check (skip if video already blocked) + -- Keyword filter check (skip if video already handled/blocked) local kwBlocked = false - if not videoBlocked and kwFilterActive then + if not videoHandled and kwFilterActive then local keywords = utils.getPhotoDirectKeywords(lrPhoto) local allowed, reason = utils.checkKeywordFilter(keywords, includePatterns, excludePatterns) if not allowed then @@ -772,7 +971,7 @@ function PublishTask.processRenderedPhotos(functionContext, exportContext) end end - if not kwBlocked and not videoBlocked then + if not kwBlocked and not videoHandled then -- Detect photo already published in this service (multi-album support) local existingPwImageId = nil if remoteId == "" then @@ -898,173 +1097,6 @@ function PublishTask.processRenderedPhotos(functionContext, exportContext) end -- end if not kwBlocked end - -- ----------------------------------------------------------------------- - -- Phase 2D — Upload des variantes vidéo (post-boucle renditions) - -- ----------------------------------------------------------------------- - if #vtkResults > 0 then - log:info("PublishTask - uploading " .. #vtkResults .. " video variant(s)") - progressScope:setCaption("Uploading video variants...") - - 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("PublishTask - skipping video (toolkit error): " .. vName .. " — " .. errMsg) - table.insert(vtkFailedVideos, "• " .. vName .. "\n " .. errMsg) - else - log:info("PublishTask - uploading video variant: " .. vr.variantPath) - progressScope:setCaption("Uploading video: " .. vName) - progressScope:setPortionComplete(idx - 1, #vtkResults) - - -- Métadonnées depuis la vidéo originale dans le catalogue LrC - local metaData = utils.getPhotoMetadata(propertyTable, vPhoto) - metaData.Albumid = albumId - -- 4B : si republication, passer l'image_id existant pour que Piwigo remplace la vidéo - metaData.Remoteid = vr.existingImageId or "" - - -- 3B — Upload de la variante : - -- Si le fichier dépasse la limite PHP du serveur → upload chunked - -- Sinon → addSimple standard - 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( - "PublishTask - video %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("PublishTask - video " .. vName .. " → addSimple upload") - uploadStatus = PiwigoAPI.updateGallery(propertyTable, vr.variantPath, metaData) - end - - if uploadStatus.status then - local imageId = uploadStatus.remoteid or "" - log:info("PublishTask - video variant uploaded, image_id=" .. imageId) - - -- 3C — Upload du poster via pwg.companion.setRepresentative - if vr.thumbnailPath and vr.thumbnailPath ~= "" - and LrFileUtils.exists(vr.thumbnailPath) then - if companionAvailable then - log:info("PublishTask - uploading poster: " .. vr.thumbnailPath) - progressScope:setCaption("Uploading poster: " .. vName) - local posterStatus = PiwigoAPI.setRepresentative( - propertyTable, imageId, vr.thumbnailPath) - if posterStatus.status then - log:info("PublishTask - poster set for image_id=" .. imageId) - else - log:warn("PublishTask - poster upload failed: " - .. (posterStatus.statusMsg or "")) - end - else - log:info("PublishTask - companion not available, skipping poster upload") - end - end - - -- 3D — Mettre à jour les métadonnées sur Piwigo (titre, description, mots-clés) - metaData.Remoteid = imageId - PiwigoAPI.updateMetadata(propertyTable, vPhoto, metaData) - - -- 3D — Enregistrer l'ID publié sur la vidéo ORIGINALE dans LrC - local remoteUrl = uploadStatus.remoteurl or "" - catalog:withWriteAccessDo("Record published video ID", function() - local publishedPhotos = publishedCollection:getPublishedPhotos() - for _, pubPhoto in ipairs(publishedPhotos) do - if pubPhoto:getPhoto().localIdentifier == vPhoto.localIdentifier then - pubPhoto:setRemoteId(imageId) - pubPhoto:setRemoteUrl(remoteUrl) - break - end - end - end, { timeout = 5 }) - - -- Stocker les métadonnées custom (pwImageURL, pwHostURL, pwVideoPreset, etc.) - local pluginData = { - pwHostURL = propertyTable.host, - albumName = albumName, - albumUrl = albumUrl, - imageUrl = remoteUrl, - pwUploadDate = os.date("%Y-%m-%d"), - pwUploadTime = os.date("%H:%M:%S"), - pwCommentSync = "", - pwVideoPreset = preset, -- 4A : stocker le preset appliqué - } - PiwigoAPI.storeMetaData(catalog, vPhoto, pluginData) - else - log:warn("PublishTask - video variant upload failed: " .. vName - .. " — " .. (uploadStatus.statusMsg or "")) - LrDialogs.message("Video Upload Failed", - "Could not upload video variant for:\n" .. vName - .. "\n\nError: " .. (uploadStatus.statusMsg or "Unknown error"), - "warning") - 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 - - progressScope:setPortionComplete(#vtkResults, #vtkResults) - end - - -- ----------------------------------------------------------------------- - -- Phase 4C — Republication métadonnées seules (vidéos sans re-upload) - -- ----------------------------------------------------------------------- - if metadataOnlyVideos and #metadataOnlyVideos > 0 then - log:info("PublishTask - updating metadata for " .. #metadataOnlyVideos .. " video(s) (metadata-only)") - progressScope:setCaption("Updating video metadata...") - - local metaOnlyFailed = {} - - 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 - local errMsg = "No Piwigo image_id found (photo may not have been published yet)" - log:warn("PublishTask - metadata-only: no image_id for " .. vName .. ", skipping") - table.insert(metaOnlyFailed, "• " .. vName .. "\n " .. errMsg) - else - log:info("PublishTask - metadata-only update for image_id=" .. imageId .. " (" .. vName .. ")") - progressScope:setCaption("Updating metadata: " .. vName) - - local metaData = utils.getPhotoMetadata(propertyTable, vPhoto) - metaData.Albumid = albumId - metaData.Remoteid = imageId - - local updateStatus = PiwigoAPI.updateMetadata(propertyTable, vPhoto, metaData) - if updateStatus.status then - log:info("PublishTask - metadata updated for image_id=" .. imageId) - else - local errMsg = updateStatus.statusMsg or "Unknown error" - log:warn("PublishTask - metadata update failed for " .. vName .. ": " .. errMsg) - table.insert(metaOnlyFailed, "• " .. vName .. "\n " .. errMsg) - end - end - end - - if #metaOnlyFailed > 0 then - LrDialogs.message("Video Metadata Update — Errors", - #metaOnlyFailed .. " video(s) could not have their metadata updated on Piwigo:\n\n" - .. table.concat(metaOnlyFailed, "\n\n"), - "warning") - end - end - progressScope:done() PWStatusManager.setPiwigoBusy(publishService, false) end diff --git a/video-toolkit/src/cli.py b/video-toolkit/src/cli.py index 6129f48..35b855d 100644 --- a/video-toolkit/src/cli.py +++ b/video-toolkit/src/cli.py @@ -54,6 +54,9 @@ def build_parser() -> argparse.ArgumentParser: 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") return p diff --git a/video-toolkit/video_toolkit.py b/video-toolkit/video_toolkit.py index fbb7e59..c26a3c7 100644 --- a/video-toolkit/video_toolkit.py +++ b/video-toolkit/video_toolkit.py @@ -32,6 +32,23 @@ def main() -> int: # 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) + + # 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": From fe1247686a8096b8c6212946b37d0aa757450ed7 Mon Sep 17 00:00:00 2001 From: Gotcha26 Date: Wed, 18 Feb 2026 10:17:57 +0100 Subject: [PATCH 32/51] Removing the false positive Lr window at the end --- piwigoPublish.lrplugin/PublishTask.lua | 130 ++++++++++++------------- 1 file changed, 64 insertions(+), 66 deletions(-) diff --git a/piwigoPublish.lrplugin/PublishTask.lua b/piwigoPublish.lrplugin/PublishTask.lua index acfbeb5..6697901 100644 --- a/piwigoPublish.lrplugin/PublishTask.lua +++ b/piwigoPublish.lrplugin/PublishTask.lua @@ -550,10 +550,13 @@ function PublishTask.processRenderedPhotos(functionContext, exportContext) -- vtkResults[i] = { photo, existingImageId, republishMode, variantPath, thumbnailPath, status, error } local vtkResults = {} local metadataOnlyVideos = {} -- 4C : videos needing metadata-only update - local vtkUploadedByLocalId = {} -- index: localIdentifier → { imageId, remoteUrl } 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 "Ce fichier est une vidéo" dialog + for _, vEntry in ipairs(videoPhotos) do + exportSession:removePhoto(vEntry.photo) + end -- Résoudre les chemins des outils (config manuelle > auto-détection > fallback PATH) local python = utils.resolveTool(propertyTable.vtkPythonPath, "python") @@ -840,6 +843,20 @@ function PublishTask.processRenderedPhotos(functionContext, exportContext) pwVideoPreset = preset, } PiwigoAPI.storeMetaData(catalog, vPhoto, pluginData) + + -- Mark video as "Published" in LrC (no rendition available — removed via removePhoto) + 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(uploadStatus.remoteurl or "") + pubPhoto:setEditedFlag(false) + log:info("PublishTask - marked video published: " .. vName .. " (image_id=" .. imageId .. ")") + break + end + end + end, { timeout = 5 }) else log:warn("PublishTask - video variant upload failed: " .. vName .. " — " .. (uploadStatus.statusMsg or "")) @@ -858,15 +875,6 @@ function PublishTask.processRenderedPhotos(functionContext, exportContext) end end - -- Construire l'index des résultats VTK par localIdentifier (pour la boucle renditions) - for _, vr in ipairs(vtkResults) do - if vr.uploadedImageId then - vtkUploadedByLocalId[vr.photo.localIdentifier] = { - imageId = vr.uploadedImageId, - remoteUrl = vr.uploadedRemoteUrl or "", - } - end - end end progressScope:setCaption("Publishing to Piwigo...") @@ -896,65 +904,16 @@ function PublishTask.processRenderedPhotos(functionContext, exportContext) local fileFormat = lrPhoto:getRawMetadata("fileFormat") local isVideo = (fileFormat == "VIDEO") - -- Video VTK: already uploaded before the loop, just consume rendition and record ID - local videoHandled = false + -- Video: blocked/VTK videos already removed before the loop via removePhoto + local videoBlocked = false if isVideo then - local vtkResult = vtkUploadedByLocalId[lrPhoto.localIdentifier] - if vtkResult then - -- Video was processed and uploaded by VTK — consume render and mark published - local vName = lrPhoto:getFormattedMetadata("fileName") or "unknown" - log:info("PublishTask - VTK video in renditions loop: " .. vName .. " — marking published (image_id=" .. vtkResult.imageId .. ")") - local success, pathOrMessage = rendition:waitForRender() - if success and LrFileUtils.exists(pathOrMessage) then - LrFileUtils.delete(pathOrMessage) - end - rendition:recordPublishedPhotoId(vtkResult.imageId) - rendition:recordPublishedPhotoUrl(vtkResult.remoteUrl) - rendition:renditionIsDone(true) - videoHandled = true - end - if not videoHandled then - -- metadata-only videos: consume render, update metadata, mark published - local metaOnlyEntry = nil - for _, vEntry in ipairs(metadataOnlyVideos) do - if vEntry.photo.localIdentifier == lrPhoto.localIdentifier then - metaOnlyEntry = vEntry - break - end - end - if metaOnlyEntry then - local vName = lrPhoto:getFormattedMetadata("fileName") or "unknown" - local imageId = metaOnlyEntry.existingImageId or "" - log:info("PublishTask - metadata-only video in renditions loop: " .. vName .. " (image_id=" .. imageId .. ")") - local success, pathOrMessage = rendition:waitForRender() - if success and LrFileUtils.exists(pathOrMessage) then - LrFileUtils.delete(pathOrMessage) - end - if imageId ~= "" then - local metaData = utils.getPhotoMetadata(propertyTable, lrPhoto) - metaData.Albumid = albumId - metaData.Remoteid = imageId - PiwigoAPI.updateMetadata(propertyTable, lrPhoto, metaData) - log:info("PublishTask - metadata updated for image_id=" .. imageId) - rendition:recordPublishedPhotoId(imageId) - rendition:recordPublishedPhotoUrl(tostring(rendition.publishedPhotoId or "")) - rendition:renditionIsDone(true) - else - log:warn("PublishTask - metadata-only: no image_id for " .. vName) - rendition:uploadFailed("No Piwigo image_id found") - end - videoHandled = true - end - end - if not videoHandled then - log:info("PublishTask.processRenderedPhotos - video detected: " - .. (lrPhoto:getFormattedMetadata("fileName") or "unknown")) - end + log:info("PublishTask.processRenderedPhotos - video detected: " + .. (lrPhoto:getFormattedMetadata("fileName") or "unknown")) end - -- Keyword filter check (skip if video already handled/blocked) + -- Keyword filter check local kwBlocked = false - if not videoHandled and kwFilterActive then + if not videoBlocked and kwFilterActive then local keywords = utils.getPhotoDirectKeywords(lrPhoto) local allowed, reason = utils.checkKeywordFilter(keywords, includePatterns, excludePatterns) if not allowed then @@ -971,7 +930,7 @@ function PublishTask.processRenderedPhotos(functionContext, exportContext) end end - if not kwBlocked and not videoHandled then + if not kwBlocked and not videoBlocked then -- Detect photo already published in this service (multi-album support) local existingPwImageId = nil if remoteId == "" then @@ -1097,6 +1056,45 @@ function PublishTask.processRenderedPhotos(functionContext, exportContext) end -- end if not kwBlocked end + -- ----------------------------------------------------------------------- + -- Phase 4C — Metadata-only video updates (videos already on Piwigo, same preset) + -- ----------------------------------------------------------------------- + if #metadataOnlyVideos > 0 then + log:info("PublishTask - updating metadata for " .. #metadataOnlyVideos .. " video(s) (metadata-only)") + 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("PublishTask - metadata-only update for image_id=" .. imageId .. " (" .. vName .. ")") + local metaData = utils.getPhotoMetadata(propertyTable, vPhoto) + metaData.Albumid = albumId + metaData.Remoteid = imageId + PiwigoAPI.updateMetadata(propertyTable, vPhoto, metaData) + log:info("PublishTask - metadata updated for image_id=" .. imageId) + + -- 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("PublishTask - marked metadata-only video published: " .. vName) + break + end + end + end, { timeout = 5 }) + else + log:warn("PublishTask - metadata-only: no image_id for " .. vName .. ", skipping") + end + end + end + progressScope:done() PWStatusManager.setPiwigoBusy(publishService, false) end From eede0792a22b903fc42f8c990b09062309427b97 Mon Sep 17 00:00:00 2001 From: Gotcha26 Date: Wed, 18 Feb 2026 13:57:02 +0100 Subject: [PATCH 33/51] =?UTF-8?q?Axe=201A=20=E2=80=94=20HDR=20detection=20?= =?UTF-8?q?+=20auto=20tonemap=20+=20SDR=20remux=20optimization?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ffprobe.py: VideoInfo gains is_hdr property and color_transfer field (detects smpte2084/PQ, arib-std-b67/HLG, smpte428, bt2020-10/12) - ffmpeg.py: HDR sources automatically get zscale tonemap filter (SDR out) when preset is not origin; origin preset always does stream copy - processor.py: SDR sources auto-downgraded to origin (remux, no transcode) while preserving the requested preset suffix for output filename naming - cli.py, status.py: minor improvements from previous session Co-Authored-By: Claude Opus 4.6 --- video-toolkit/src/cli.py | 17 +++++++++++++++-- video-toolkit/src/ffmpeg.py | 29 +++++++++++++++++++++++++++-- video-toolkit/src/ffprobe.py | 21 +++++++++++++++++++++ video-toolkit/src/processor.py | 19 +++++++++++++++++-- video-toolkit/src/status.py | 4 ++++ 5 files changed, 84 insertions(+), 6 deletions(-) diff --git a/video-toolkit/src/cli.py b/video-toolkit/src/cli.py index 35b855d..37dedd3 100644 --- a/video-toolkit/src/cli.py +++ b/video-toolkit/src/cli.py @@ -226,6 +226,9 @@ def _batch_progress(done: int, total_: int, current: str) -> None: "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", @@ -466,7 +469,8 @@ def _menu_probe(self) -> None: # Rapport self.fmt.print_section_header("RÉSULTAT PROBE") - self.fmt.aligned_output([ + + rows = [ ("Fichier", video_path.name), ("Résolution", info.resolution), ("Durée", info.duration_str), @@ -478,7 +482,16 @@ def _menu_probe(self) -> None: ("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() diff --git a/video-toolkit/src/ffmpeg.py b/video-toolkit/src/ffmpeg.py index 4e53c64..48d156f 100644 --- a/video-toolkit/src/ffmpeg.py +++ b/video-toolkit/src/ffmpeg.py @@ -16,6 +16,7 @@ from pathlib import Path from typing import Callable +from .ffprobe import VideoInfo from .presets import VideoPreset, PresetManager @@ -66,17 +67,21 @@ def transcode( 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 + if preset.is_origin: cmd = self._build_remux_cmd(input_path, output_path) else: @@ -87,7 +92,7 @@ def transcode( src_width, src_height, preset ) cmd = self._build_transcode_cmd( - input_path, output_path, preset, scale_filter + input_path, output_path, preset, scale_filter, is_hdr=is_hdr ) if dry_run: @@ -207,10 +212,30 @@ def _build_transcode_cmd( output_path: str, preset: VideoPreset, scale_filter: str, + is_hdr: bool = False, ) -> list[str]: vb = preset.video_bitrate ab = preset.audio_bitrate + # Build video filter chain + if is_hdr: + # HDR → SDR tonemap pipeline: + # 1. Convert to linear light in BT.2020 space (zscale) + # 2. Tonemap from linear HDR to linear SDR (hable algorithm) + # 3. Convert from BT.2020 to BT.709 color space + # 4. Scale to target resolution + 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 = scale_filter + cmd = [ self.binary, "-i", input_path, @@ -222,7 +247,7 @@ def _build_transcode_cmd( "-b:v", f"{vb}k", "-maxrate", f"{int(vb * 1.2)}k", "-bufsize", f"{vb * 2}k", - "-vf", scale_filter, + "-vf", vf, "-pix_fmt", preset.pixel_format, # Audio "-c:a", preset.audio_codec, diff --git a/video-toolkit/src/ffprobe.py b/video-toolkit/src/ffprobe.py index ea41d48..0f84f87 100644 --- a/video-toolkit/src/ffprobe.py +++ b/video-toolkit/src/ffprobe.py @@ -30,6 +30,15 @@ class VideoInfo: 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: @@ -58,6 +67,10 @@ def to_dict(self) -> dict: "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, } @@ -194,6 +207,11 @@ def _parse_probe_data(path: str, data: dict) -> VideoInfo: 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, @@ -206,6 +224,9 @@ def _parse_probe_data(path: str, data: dict) -> VideoInfo: fps=fps, size=size, container=container, + color_transfer=color_transfer, + color_primaries=color_primaries, + color_space=color_space, ) diff --git a/video-toolkit/src/processor.py b/video-toolkit/src/processor.py index 5ac3f1a..fa8853c 100644 --- a/video-toolkit/src/processor.py +++ b/video-toolkit/src/processor.py @@ -116,6 +116,17 @@ def process( 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) @@ -130,12 +141,15 @@ def process( 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 - suffix = preset.suffix # "_medium", "_small", "" (origin) - variant_name = f"{stem}{suffix}.mp4" if suffix else f"{stem}.mp4" + # 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" @@ -193,6 +207,7 @@ def _transcode_progress(pct: int) -> None: 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)) diff --git a/video-toolkit/src/status.py b/video-toolkit/src/status.py index e5dcae5..39fd945 100644 --- a/video-toolkit/src/status.py +++ b/video-toolkit/src/status.py @@ -78,6 +78,8 @@ def set_source( video_codec: str, audio_codec: str, fps: float, + is_hdr: bool = False, + color_transfer: str = "", ) -> None: self._data["source"] = { "path": str(self._video), @@ -90,6 +92,8 @@ def set_source( "video_codec": video_codec, "audio_codec": audio_codec, "fps": fps, + "is_hdr": is_hdr, + "color_transfer": color_transfer, } def get_source(self) -> dict: From ecd230475482189c237573207434c77a45001c1d Mon Sep 17 00:00:00 2001 From: Gotcha26 Date: Wed, 18 Feb 2026 14:12:25 +0100 Subject: [PATCH 34/51] =?UTF-8?q?Axe=201B=20=E2=80=94=20setVideoInfo=20app?= =?UTF-8?q?el=C3=A9=20en=20mode=20metadata-only=20via=20fichier=20.vtk?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit En republication avec même preset, PublishTask lit le fichier .vtk/.json pour récupérer les dimensions (width×height) et la taille de la variante déjà transcodée, puis appelle PiwigoAPI.setVideoInfo pour mettre à jour Piwigo. Co-Authored-By: Claude Opus 4.6 --- piwigoPublish.lrplugin/PublishTask.lua | 51 ++++++++++++++++++++++++++ 1 file changed, 51 insertions(+) diff --git a/piwigoPublish.lrplugin/PublishTask.lua b/piwigoPublish.lrplugin/PublishTask.lua index 6697901..974370c 100644 --- a/piwigoPublish.lrplugin/PublishTask.lua +++ b/piwigoPublish.lrplugin/PublishTask.lua @@ -729,6 +729,10 @@ function PublishTask.processRenderedPhotos(functionContext, exportContext) 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, status = r.status or "error", error = r.error or "", }) @@ -823,6 +827,16 @@ function PublishTask.processRenderedPhotos(functionContext, exportContext) end end + -- Set video dimensions on Piwigo (via Companion) + if companionAvailable and vr.videoWidth > 0 and vr.videoHeight > 0 then + log:info("PublishTask - setting video info: " + .. vr.videoWidth .. "x" .. vr.videoHeight + .. " size=" .. vr.videoSize) + PiwigoAPI.setVideoInfo( + propertyTable, imageId, + vr.videoWidth, vr.videoHeight, vr.videoSize) + end + -- Mettre à jour les métadonnées sur Piwigo metaData.Remoteid = imageId PiwigoAPI.updateMetadata(propertyTable, vPhoto, metaData) @@ -1076,6 +1090,43 @@ function PublishTask.processRenderedPhotos(functionContext, exportContext) PiwigoAPI.updateMetadata(propertyTable, vPhoto, metaData) log:info("PublishTask - metadata updated for image_id=" .. imageId) + -- setVideoInfo depuis le fichier .vtk (dimensions de la variante déjà transcodée) + 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 + -- Déduire width/height depuis resolution si absent + 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("PublishTask - setVideoInfo (metadata-only) image_id=" + .. imageId .. " " .. vw .. "x" .. vh .. " size=" .. vs) + PiwigoAPI.setVideoInfo(propertyTable, imageId, vw, vh, vs) + end + else + log:info("PublishTask - no .vtk variant data for preset=" .. preset .. " (" .. vName .. ")") + end + else + log:info("PublishTask - .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() From d6dd3a0079932d8900e6f74f30e274640160044e Mon Sep 17 00:00:00 2001 From: Gotcha26 Date: Wed, 18 Feb 2026 16:12:05 +0100 Subject: [PATCH 35/51] Fix VTK integration: .bat wrapper, force re_upload, addPhotoByRemoteId - LrTasks.execute via temporary .bat file to avoid nested quotes issue on Windows (cmd /c + multiple quoted paths = silent failure) - Delete stale VTK log before launch to prevent reading old results - Remove metadata_only mode: republish always triggers VTK (cache decides) - Use publishedCollection:addPhotoByRemoteId to mark videos as published (works for both new and existing photos, unlike getPublishedPhotos loop) Co-Authored-By: Claude Opus 4.6 --- piwigoPublish.lrplugin/PublishTask.lua | 82 +++++++++++--------------- 1 file changed, 35 insertions(+), 47 deletions(-) diff --git a/piwigoPublish.lrplugin/PublishTask.lua b/piwigoPublish.lrplugin/PublishTask.lua index 974370c..1f3656f 100644 --- a/piwigoPublish.lrplugin/PublishTask.lua +++ b/piwigoPublish.lrplugin/PublishTask.lua @@ -372,11 +372,12 @@ function PublishTask.processRenderedPhotos(functionContext, exportContext) -- No preset recorded → video was never processed by VTK → need full processing republishMode = "re_upload" elseif appliedPreset ~= currentPreset then - -- Preset changed → need re-encode + -- Preset changed → need re-encode (force=true in batch) republishMode = "re_upload" else - -- Same preset → metadata only - republishMode = "metadata_only" + -- Same preset → still re_upload (VTK cache decides whether to re-encode) + -- processRenderedPhotos is only called for full republish, not metadata-only + republishMode = "re_upload" end end end @@ -635,7 +636,23 @@ function PublishTask.processRenderedPhotos(functionContext, exportContext) .. ' --log-file "' .. vtkLogPath .. '"' .. ffmpegArg .. exiftoolArg .. presetsArg + -- Supprimer l'ancien log pour éviter de lire des résultats périmés si VTK crash + if LrFileUtils.exists(vtkLogPath) then + LrFileUtils.delete(vtkLogPath) + end + + -- Écrire un fichier .bat temporaire pour contourner le problème de guillemets + -- imbriqués avec LrTasks.execute sur Windows (cmd /c + guillemets multiples) + 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("PublishTask - VTK command: " .. cmd) + log:info("PublishTask - VTK bat file: " .. batPath) log:info("PublishTask - VTK log file: " .. vtkLogPath) -- Log du contenu du batch file pour diagnostic local bfDiag = io.open(batchFilePath, "r") @@ -647,41 +664,17 @@ function PublishTask.processRenderedPhotos(functionContext, exportContext) -- Configurer la progression LrC pendant le traitement vidéo progressScope:setCaption("Video Toolkit — Processing " .. batchVideoCount .. " video(s)...") - -- Lancer en async + polling du fichier statut - local vtkDone = false - local vtkExitCode = nil + -- Lancer le .bat (LrTasks.execute bloque le thread courant) local vtkCancelled = false - - LrTasks.startAsyncTask(function() - vtkExitCode = LrTasks.execute(cmd) - log:info("PublishTask - LrTasks.execute returned: " .. tostring(vtkExitCode) .. " (type=" .. type(vtkExitCode) .. ")") - vtkDone = true - end) - - -- Polling toutes les 500ms - while not vtkDone do - LrTasks.sleep(0.5) - - if progressScope:isCanceled() then - log:info("PublishTask - VTK polling cancelled by user") - vtkCancelled = true - break - end - - -- Lire la progression depuis le fichier statut - local sf = io.open(statusFilePath, "r") - if sf then - local content = sf:read("*all") - sf:close() - local ok, statusData = pcall(function() return JSON:decode(content) end) - if ok and statusData then - local pct = tonumber(statusData.progress) or 0 - progressScope:setPortionComplete(pct, 100) - if statusData.current_file and statusData.current_file ~= "" then - local fname = LrPathUtils.leafName(statusData.current_file) - progressScope:setCaption("Video Toolkit — " .. fname) - end - end + local vtkExitCode = LrTasks.execute('"' .. batPath .. '"') + log:info("PublishTask - LrTasks.execute returned: " .. tostring(vtkExitCode) .. " (type=" .. type(vtkExitCode) .. ")") + + -- Si le log file n'existe pas encore, attendre un peu (VTK peut écrire avec un léger délai) + if not LrFileUtils.exists(vtkLogPath) then + log:info("PublishTask - waiting for VTK log file...") + for _ = 1, 20 do -- max 10 secondes + LrTasks.sleep(0.5) + if LrFileUtils.exists(vtkLogPath) then break end end end @@ -859,17 +852,12 @@ function PublishTask.processRenderedPhotos(functionContext, exportContext) PiwigoAPI.storeMetaData(catalog, vPhoto, pluginData) -- Mark video as "Published" in LrC (no rendition available — removed via removePhoto) + -- addPhotoByRemoteId works for both new and existing published photos 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(uploadStatus.remoteurl or "") - pubPhoto:setEditedFlag(false) - log:info("PublishTask - marked video published: " .. vName .. " (image_id=" .. imageId .. ")") - break - end - end + publishedCollection:addPhotoByRemoteId( + vPhoto, tostring(imageId), + uploadStatus.remoteurl or "", true) + log:info("PublishTask - marked video published: " .. vName .. " (image_id=" .. imageId .. ")") end, { timeout = 5 }) else log:warn("PublishTask - video variant upload failed: " .. vName From 3c5edc533137fe197a67196d5408a2842003331f Mon Sep 17 00:00:00 2001 From: Gotcha26 Date: Wed, 18 Feb 2026 16:16:02 +0100 Subject: [PATCH 36/51] =?UTF-8?q?Axe=201B=20=E2=80=94=20PiwigoAPI.setVideo?= =?UTF-8?q?Info=20+=20companion=20pwg.companion.setVideoInfo?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - PiwigoAPI.lua: new setVideoInfo(propertyTable, imageId, w, h, filesize) calls pwg.companion.setVideoInfo to store video dimensions in Piwigo DB - lightroom-companion/main.inc.php: new API method companion_set_video_info updates width, height, filesize (KB) in images table for a given image_id Co-Authored-By: Claude Opus 4.6 --- lightroom-companion/main.inc.php | 95 +++++++++++++++++++++++++++- piwigoPublish.lrplugin/PiwigoAPI.lua | 34 ++++++++++ 2 files changed, 128 insertions(+), 1 deletion(-) diff --git a/lightroom-companion/main.inc.php b/lightroom-companion/main.inc.php index a54b07f..f8e4579 100644 --- a/lightroom-companion/main.inc.php +++ b/lightroom-companion/main.inc.php @@ -1,7 +1,7 @@ true) ); + + $service->addMethod( + 'pwg.companion.setVideoInfo', + 'companion_set_video_info', + array( + 'image_id' => array( + 'default' => null, + 'type' => WS_TYPE_INT, + 'info' => 'Piwigo image/video ID', + ), + 'width' => array( + 'default' => null, + 'type' => WS_TYPE_INT, + 'info' => 'Video width in pixels', + ), + 'height' => array( + 'default' => null, + 'type' => WS_TYPE_INT, + 'info' => 'Video height in pixels', + ), + 'filesize' => array( + 'default' => null, + 'type' => WS_TYPE_INT, + 'info' => 'Video file size in bytes (optional)', + ), + ), + 'Sets video dimensions and optional filesize in the Piwigo images table.', + null, + array('admin_only' => true) + ); } // ========================================================================= @@ -332,6 +362,69 @@ function companion_set_representative($params, &$service) ); } +// ========================================================================= +// pwg.companion.setVideoInfo +// ========================================================================= +function companion_set_video_info($params, &$service) +{ + $image_id = (int)$params['image_id']; + if ($image_id <= 0) + { + return new PwgError(WS_ERR_INVALID_PARAM, 'image_id must be a positive integer'); + } + + // Verify image exists + $query = 'SELECT id FROM ' . IMAGES_TABLE . ' WHERE id = ' . $image_id . ';'; + $result = pwg_query($query); + $row = pwg_db_fetch_assoc($result); + if (!$row) + { + return new PwgError(404, 'Image ' . $image_id . ' not found'); + } + + // Build SET clause from provided parameters + $updates = array(); + + if (isset($params['width']) && $params['width'] !== null) + { + $width = (int)$params['width']; + if ($width > 0) $updates[] = 'width = ' . $width; + } + + if (isset($params['height']) && $params['height'] !== null) + { + $height = (int)$params['height']; + if ($height > 0) $updates[] = 'height = ' . $height; + } + + if (isset($params['filesize']) && $params['filesize'] !== null) + { + // Piwigo stores filesize in KB in the images table + $filesize_bytes = (int)$params['filesize']; + if ($filesize_bytes > 0) + { + $filesize_kb = (int)ceil($filesize_bytes / 1024); + $updates[] = 'filesize = ' . $filesize_kb; + } + } + + if (empty($updates)) + { + return new PwgError(WS_ERR_INVALID_PARAM, 'At least one of width, height, or filesize must be provided'); + } + + $query = 'UPDATE ' . IMAGES_TABLE + . ' SET ' . implode(', ', $updates) + . ' WHERE id = ' . $image_id . ';'; + pwg_query($query); + + return array( + 'status' => 'ok', + 'image_id' => $image_id, + 'updated' => $updates, + ); +} + // ========================================================================= // Helpers // ========================================================================= diff --git a/piwigoPublish.lrplugin/PiwigoAPI.lua b/piwigoPublish.lrplugin/PiwigoAPI.lua index f8c7e5a..c4e51fc 100644 --- a/piwigoPublish.lrplugin/PiwigoAPI.lua +++ b/piwigoPublish.lrplugin/PiwigoAPI.lua @@ -3472,5 +3472,39 @@ function PiwigoAPI.setRepresentative(propertyTable, imageId, posterPath) return callStatus end +-- ************************************************* +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 + 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 + callStatus.statusMsg = "setVideoInfo - failed: " .. (postResp.statusMsg or "") + log:info("PiwigoAPI." .. callStatus.statusMsg) + end + return callStatus +end + -- ************************************************* return PiwigoAPI From 155596a999a3a01d6f7a39bf49dc26e6a27d32b9 Mon Sep 17 00:00:00 2001 From: Gotcha26 Date: Wed, 18 Feb 2026 16:17:33 +0100 Subject: [PATCH 37/51] Fix thumbnail from SDR variant + info dialog before transcode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - processor.py: thumbnail extracted from transcoded variant (SDR colors) instead of HDR source — avoids washed-out colors in poster image - PublishTask.lua: LrDialogs.message informs user before VTK launch that transcoding may take several minutes and Lightroom will be frozen Co-Authored-By: Claude Opus 4.6 --- piwigoPublish.lrplugin/PublishTask.lua | 8 ++++++++ video-toolkit/src/processor.py | 12 ++++++++++-- 2 files changed, 18 insertions(+), 2 deletions(-) diff --git a/piwigoPublish.lrplugin/PublishTask.lua b/piwigoPublish.lrplugin/PublishTask.lua index 1f3656f..5b1d676 100644 --- a/piwigoPublish.lrplugin/PublishTask.lua +++ b/piwigoPublish.lrplugin/PublishTask.lua @@ -664,6 +664,14 @@ function PublishTask.processRenderedPhotos(functionContext, exportContext) -- Configurer la progression LrC pendant le traitement vidéo progressScope:setCaption("Video Toolkit — Processing " .. batchVideoCount .. " video(s)...") + -- Inform user that transcoding may take a long time + 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") + -- Lancer le .bat (LrTasks.execute bloque le thread courant) local vtkCancelled = false local vtkExitCode = LrTasks.execute('"' .. batPath .. '"') diff --git a/video-toolkit/src/processor.py b/video-toolkit/src/processor.py index fa8853c..3c624ff 100644 --- a/video-toolkit/src/processor.py +++ b/video-toolkit/src/processor.py @@ -238,10 +238,18 @@ def _transcode_progress(pct: int) -> None: 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=input_path, + input_path=thumb_source, output_path=thumbnail_path, - duration=info.duration, + duration=thumb_duration, timestamp_pct=self._thumb_pct, max_width=self._thumb_max_w, dry_run=dry_run, From bac48eec149b3af2d7510a6984c0704ec5459b80 Mon Sep 17 00:00:00 2001 From: Gotcha26 Date: Thu, 19 Feb 2026 13:22:32 +0100 Subject: [PATCH 38/51] =?UTF-8?q?Phase=201A=20&=20Axe=203=20=E2=80=94=20Re?= =?UTF-8?q?factor:=20VTK=20integration=20+=20hwaccel=20support=20+=20compa?= =?UTF-8?q?nion=20plugin=20update?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Refactor: extract VTK logic into vtk_core.lua and vtk_ui.lua - Add hwaccel support (NVIDIA/AMD/Intel) with auto-detection - Reorganize lightroom-companion plugin structure (lightroom_companion/) - Add VIDEO-TOOLKIT-USER-GUIDE documentation - Improve video processing pipeline with metadata handling - Update icon assets (email, github) - Remove requirements.txt (Python 3.8+ stdlib only) Co-Authored-By: Claude Haiku 4.5 --- lightroom-companion/main.inc.php | 507 ------- .../admin.php | 70 +- .../admin.tpl | 164 ++- lightroom_companion/assets/README.txt | 13 + lightroom_companion/main.inc.php | 1270 +++++++++++++++++ piwigoPublish.lrplugin/Init.lua | 13 +- piwigoPublish.lrplugin/PiwigoAPI.lua | 47 + .../PluginInfoDialogSections.lua | 92 +- .../PublishDialogSections.lua | 376 +---- .../PublishServiceProvider.lua | 3 +- piwigoPublish.lrplugin/PublishTask.lua | 642 +-------- piwigoPublish.lrplugin/UIHelpers.lua | 35 +- piwigoPublish.lrplugin/icons/email_32.png | Bin 0 -> 652 bytes piwigoPublish.lrplugin/icons/github_32.png | Bin 0 -> 958 bytes piwigoPublish.lrplugin/utils.lua | 19 + piwigoPublish.lrplugin/vtk_core.lua | 679 +++++++++ piwigoPublish.lrplugin/vtk_ui.lua | 365 +++++ video-toolkit/INSTALL.md | 78 +- video-toolkit/VIDEO-TOOLKIT-USER-GUIDE.md | 241 ++++ video-toolkit/requirements.txt | 12 - video-toolkit/src/__init__.py | 8 + video-toolkit/src/cli.py | 85 +- video-toolkit/src/config.py | 64 +- video-toolkit/src/ffmpeg.py | 68 +- video-toolkit/src/ffprobe.py | 5 + video-toolkit/src/hwaccel.py | 192 +++ video-toolkit/src/metadata.py | 6 + video-toolkit/src/processor.py | 42 +- video-toolkit/src/ui.py | 23 +- video-toolkit/tests/test_hasher.py | 4 +- video-toolkit/video_toolkit.py | 12 + 31 files changed, 3495 insertions(+), 1640 deletions(-) delete mode 100644 lightroom-companion/main.inc.php rename {lightroom-companion => lightroom_companion}/admin.php (71%) rename {lightroom-companion => lightroom_companion}/admin.tpl (60%) create mode 100644 lightroom_companion/assets/README.txt create mode 100644 lightroom_companion/main.inc.php create mode 100644 piwigoPublish.lrplugin/icons/email_32.png create mode 100644 piwigoPublish.lrplugin/icons/github_32.png create mode 100644 piwigoPublish.lrplugin/vtk_core.lua create mode 100644 piwigoPublish.lrplugin/vtk_ui.lua create mode 100644 video-toolkit/VIDEO-TOOLKIT-USER-GUIDE.md delete mode 100644 video-toolkit/requirements.txt create mode 100644 video-toolkit/src/hwaccel.py diff --git a/lightroom-companion/main.inc.php b/lightroom-companion/main.inc.php deleted file mode 100644 index f8e4579..0000000 --- a/lightroom-companion/main.inc.php +++ /dev/null @@ -1,507 +0,0 @@ - 'Lightroom Companion', - 'URL' => get_admin_plugin_menu_link(__DIR__ . '/admin.php'), - )); - return $menu; -} - -function companion_add_methods($arr) -{ - $service = &$arr[0]; - - $service->addMethod( - 'pwg.companion.getConfig', - 'companion_get_config', - array(), - 'Returns server configuration: PHP, upload limits, graphics libs, FFmpeg, video readiness.', - null, - array('admin_only' => true) - ); - - $service->addMethod( - 'pwg.companion.enableVideoSupport', - 'companion_enable_video_support', - array(), - 'Enables video upload support by writing upload_form_all_types and file_ext to local config.', - null, - array('admin_only' => true) - ); - - $service->addMethod( - 'pwg.companion.setRepresentative', - 'companion_set_representative', - array( - 'image_id' => array( - 'default' => null, - 'type' => WS_TYPE_INT, - 'info' => 'Piwigo image/video ID', - ), - ), - 'Upload a poster/thumbnail image as the representative for a video.', - null, - array('admin_only' => true) - ); - - $service->addMethod( - 'pwg.companion.setVideoInfo', - 'companion_set_video_info', - array( - 'image_id' => array( - 'default' => null, - 'type' => WS_TYPE_INT, - 'info' => 'Piwigo image/video ID', - ), - 'width' => array( - 'default' => null, - 'type' => WS_TYPE_INT, - 'info' => 'Video width in pixels', - ), - 'height' => array( - 'default' => null, - 'type' => WS_TYPE_INT, - 'info' => 'Video height in pixels', - ), - 'filesize' => array( - 'default' => null, - 'type' => WS_TYPE_INT, - 'info' => 'Video file size in bytes (optional)', - ), - ), - 'Sets video dimensions and optional filesize in the Piwigo images table.', - null, - array('admin_only' => true) - ); -} - -// ========================================================================= -// pwg.companion.getConfig -// ========================================================================= -function companion_get_config($params, &$service) -{ - $result = array(); - - // ----- PHP ----- - $disabled_functions = array_map('trim', explode(',', ini_get('disable_functions'))); - $exec_available = function_exists('exec') && !in_array('exec', $disabled_functions); - - $result['php'] = array( - 'version' => PHP_VERSION, - 'memory_limit' => ini_get('memory_limit'), - 'upload_max_filesize' => ini_get('upload_max_filesize'), - 'post_max_size' => ini_get('post_max_size'), - 'max_execution_time' => ini_get('max_execution_time'), - 'max_input_time' => ini_get('max_input_time'), - 'max_file_uploads' => ini_get('max_file_uploads'), - 'exec_available' => $exec_available, - 'disabled_functions' => $exec_available ? '' : ini_get('disable_functions'), - ); - - // ----- Graphics library ----- - $gfx = array('gd' => false, 'imagick' => false); - - if (function_exists('gd_info')) - { - $gd = gd_info(); - $gfx['gd'] = array( - 'version' => isset($gd['GD Version']) ? $gd['GD Version'] : 'unknown', - 'jpeg' => !empty($gd['JPEG Support']), - 'png' => !empty($gd['PNG Support']), - 'webp' => !empty($gd['WebP Support']), - ); - } - - if (extension_loaded('imagick')) - { - try { - $im = new Imagick(); - $ver = Imagick::getVersion(); - $gfx['imagick'] = array( - 'version' => isset($ver['versionString']) ? $ver['versionString'] : 'unknown', - ); - } catch (Exception $e) { - $gfx['imagick'] = array('version' => 'error: ' . $e->getMessage()); - } - } - - $result['graphics'] = $gfx; - - // ----- CLI tools (FFmpeg, ExifTool, MediaInfo) ----- - if ($exec_available) - { - $result['ffmpeg'] = companion_detect_tool('ffmpeg', '-version'); - $result['ffprobe'] = companion_detect_tool('ffprobe', '-version'); - $result['exiftool'] = companion_detect_tool('exiftool', '-ver'); - $result['mediainfo'] = companion_detect_tool('mediainfo', '--Version'); - } - else - { - $notice = 'exec() is disabled by PHP configuration'; - $result['ffmpeg'] = array('installed' => false, 'notice' => $notice); - $result['ffprobe'] = array('installed' => false, 'notice' => $notice); - $result['exiftool'] = array('installed' => false, 'notice' => $notice); - $result['mediainfo'] = array('installed' => false, 'notice' => $notice); - } - - // ----- Piwigo config (video-relevant) ----- - global $conf; - - $upload_all = isset($conf['upload_form_all_types']) ? (bool)$conf['upload_form_all_types'] : false; - $file_ext = isset($conf['file_ext']) ? $conf['file_ext'] : array(); - $pic_ext = isset($conf['picture_ext']) ? $conf['picture_ext'] : array(); - - // Check for video extensions - $video_exts = array('mp4', 'm4v', 'ogg', 'ogv', 'webm', 'webmv', 'mpg', 'mpeg', 'mov', 'avi'); - $found_video_exts = array_values(array_intersect($file_ext, $video_exts)); - - $result['piwigo'] = array( - 'version' => PHPWG_VERSION, - 'upload_form_all_types' => $upload_all, - 'file_ext' => $file_ext, - 'picture_ext' => $pic_ext, - 'video_ext_configured' => $found_video_exts, - 'video_ready' => $upload_all && !empty($found_video_exts), - 'local_config_writable' => companion_is_local_config_writable(), - ); - - // ----- OS ----- - $result['server'] = array( - 'os' => PHP_OS, - 'software' => isset($_SERVER['SERVER_SOFTWARE']) ? $_SERVER['SERVER_SOFTWARE'] : 'unknown', - ); - - return $result; -} - -// ========================================================================= -// pwg.companion.enableVideoSupport -// ========================================================================= -function companion_enable_video_support($params, &$service) -{ - global $conf; - - $config_path = PHPWG_ROOT_PATH . 'local/config/config.inc.php'; - - // Check if already configured - $upload_all = isset($conf['upload_form_all_types']) ? (bool)$conf['upload_form_all_types'] : false; - $file_ext = isset($conf['file_ext']) ? $conf['file_ext'] : array(); - $video_exts = array('mp4', 'm4v', 'ogg', 'ogv', 'webm'); - $found = array_intersect($file_ext, $video_exts); - - if ($upload_all && count($found) >= count($video_exts)) - { - return array( - 'status' => 'already_configured', - 'message' => 'Video support is already enabled.', - ); - } - - // Check writable - if (!companion_is_local_config_writable()) - { - return array( - 'status' => 'error', - 'message' => 'Cannot write to ' . $config_path . '. Check file permissions.', - ); - } - - // Read current file content - $content = ''; - if (file_exists($config_path)) - { - $content = file_get_contents($config_path); - } - - // Build lines to append - $lines_to_add = array(); - $lines_to_add[] = ''; - $lines_to_add[] = '// --- PiwigoPublish Companion: video upload support ---'; - - if (!$upload_all) - { - $lines_to_add[] = "\$conf['upload_form_all_types'] = true;"; - } - - // Always write file_ext with merge to ensure video extensions are present - $lines_to_add[] = "\$conf['file_ext'] = array_merge("; - $lines_to_add[] = " \$conf['picture_ext'],"; - $lines_to_add[] = " array('mp4', 'm4v', 'ogg', 'ogv', 'webm')"; - $lines_to_add[] = ");"; - - // Check if file has PHP opening tag - $php_open_tag = '<' . '?php'; - $php_close_tag = '?' . '>'; - if (empty($content) || strpos($content, $php_open_tag) === false) - { - $content = $php_open_tag . "\n" . implode("\n", $lines_to_add) . "\n"; - } - else - { - /* Remove trailing close-tag if present (we'll leave the file open) */ - $content = rtrim($content); - if (substr($content, -2) === $php_close_tag) - { - $content = rtrim(substr($content, 0, -2)); - } - $content .= "\n" . implode("\n", $lines_to_add) . "\n"; - } - - // Write - $written = @file_put_contents($config_path, $content); - if ($written === false) - { - return array( - 'status' => 'error', - 'message' => 'Failed to write to ' . $config_path, - ); - } - - return array( - 'status' => 'ok', - 'message' => 'Video support has been enabled. Video extensions (mp4, m4v, ogg, ogv, webm) are now allowed.', - ); -} - -// ========================================================================= -// pwg.companion.setRepresentative -// ========================================================================= -function companion_set_representative($params, &$service) -{ - global $conf; - - $image_id = (int)$params['image_id']; - if ($image_id <= 0) - { - return new PwgError(WS_ERR_INVALID_PARAM, 'image_id must be a positive integer'); - } - - // Verify image exists - $query = 'SELECT id, path FROM ' . IMAGES_TABLE . ' WHERE id = ' . $image_id . ';'; - $result = pwg_query($query); - $row = pwg_db_fetch_assoc($result); - if (!$row) - { - return new PwgError(404, 'Image ' . $image_id . ' not found'); - } - - // Expect an uploaded file named 'file' - if (empty($_FILES['file']) || $_FILES['file']['error'] !== UPLOAD_ERR_OK) - { - $err = isset($_FILES['file']['error']) ? $_FILES['file']['error'] : 'no file'; - return new PwgError(WS_ERR_INVALID_PARAM, 'No valid file uploaded (error: ' . $err . ')'); - } - - // Determine storage directory from existing image path - // path is relative to PHPWG_ROOT_PATH, e.g. "upload/2024/01/01/2024010...jpg" - $image_dir = PHPWG_ROOT_PATH . dirname($row['path']); - if (!is_dir($image_dir)) - { - return new PwgError(500, 'Image directory not found: ' . $image_dir); - } - - // Build representative filename: same basename, extension = uploaded file extension - $uploaded_ext = strtolower(pathinfo($_FILES['file']['name'], PATHINFO_EXTENSION)); - if (!in_array($uploaded_ext, array('jpg', 'jpeg', 'png', 'webp'))) - { - return new PwgError(WS_ERR_INVALID_PARAM, 'Poster must be jpg, jpeg, png or webp'); - } - - // Piwigo representative: stored in pwg_representative/ subdirectory - $image_basename = pathinfo($row['path'], PATHINFO_FILENAME); - $representative_filename = $image_basename . '.' . $uploaded_ext; - $representative_dir = $image_dir . '/pwg_representative'; - if (!is_dir($representative_dir)) - { - @mkdir($representative_dir, 0755, true); - } - $representative_path = $representative_dir . '/' . $representative_filename; - - if (!move_uploaded_file($_FILES['file']['tmp_name'], $representative_path)) - { - return new PwgError(500, 'Failed to move uploaded poster to ' . $representative_path); - } - - // Invalidate Piwigo derivative cache for this image - $query = 'UPDATE ' . IMAGES_TABLE - . " SET representative_ext = '" . pwg_db_real_escape_string($uploaded_ext) . "'" - . ' WHERE id = ' . $image_id . ';'; - pwg_query($query); - - // Delete cached derivatives so Piwigo regenerates thumbnails - $image_path = PHPWG_ROOT_PATH . $row['path']; - if (function_exists('delete_element_derivatives')) - { - $element_info = array('id' => $image_id, 'path' => $row['path']); - delete_element_derivatives($element_info); - } - - return array( - 'status' => 'ok', - 'image_id' => $image_id, - 'representative_ext' => $uploaded_ext, - 'representative_path' => $representative_filename, - ); -} - -// ========================================================================= -// pwg.companion.setVideoInfo -// ========================================================================= -function companion_set_video_info($params, &$service) -{ - $image_id = (int)$params['image_id']; - if ($image_id <= 0) - { - return new PwgError(WS_ERR_INVALID_PARAM, 'image_id must be a positive integer'); - } - - // Verify image exists - $query = 'SELECT id FROM ' . IMAGES_TABLE . ' WHERE id = ' . $image_id . ';'; - $result = pwg_query($query); - $row = pwg_db_fetch_assoc($result); - if (!$row) - { - return new PwgError(404, 'Image ' . $image_id . ' not found'); - } - - // Build SET clause from provided parameters - $updates = array(); - - if (isset($params['width']) && $params['width'] !== null) - { - $width = (int)$params['width']; - if ($width > 0) $updates[] = 'width = ' . $width; - } - - if (isset($params['height']) && $params['height'] !== null) - { - $height = (int)$params['height']; - if ($height > 0) $updates[] = 'height = ' . $height; - } - - if (isset($params['filesize']) && $params['filesize'] !== null) - { - // Piwigo stores filesize in KB in the images table - $filesize_bytes = (int)$params['filesize']; - if ($filesize_bytes > 0) - { - $filesize_kb = (int)ceil($filesize_bytes / 1024); - $updates[] = 'filesize = ' . $filesize_kb; - } - } - - if (empty($updates)) - { - return new PwgError(WS_ERR_INVALID_PARAM, 'At least one of width, height, or filesize must be provided'); - } - - $query = 'UPDATE ' . IMAGES_TABLE - . ' SET ' . implode(', ', $updates) - . ' WHERE id = ' . $image_id . ';'; - pwg_query($query); - - return array( - 'status' => 'ok', - 'image_id' => $image_id, - 'updated' => $updates, - ); -} - -// ========================================================================= -// Helpers -// ========================================================================= - -/** - * Check if local config file is writable (or parent dir is writable if file doesn't exist) - */ -function companion_is_local_config_writable() -{ - $config_path = PHPWG_ROOT_PATH . 'local/config/config.inc.php'; - if (file_exists($config_path)) - { - return is_writable($config_path); - } - // File doesn't exist — check if directory is writable - $dir = dirname($config_path); - return is_dir($dir) && is_writable($dir); -} - -/** - * Detect a CLI tool: find its path and get version output - */ -function companion_detect_tool($name, $version_flag) -{ - $result = array('installed' => false); - - $path = companion_find_executable($name); - if ($path === false) - { - return $result; - } - - $result['installed'] = true; - $result['path'] = $path; - - $output = array(); - @exec(escapeshellarg($path) . ' ' . $version_flag . ' 2>&1', $output); - if (!empty($output)) - { - $result['version'] = trim($output[0]); - } - - return $result; -} - -/** - * Try to find an executable in PATH or common locations - */ -function companion_find_executable($name) -{ - // Try which/where - $cmd = (PHP_OS_FAMILY === 'Windows') ? 'where' : 'which'; - $output = array(); - $return_var = -1; - @exec($cmd . ' ' . escapeshellarg($name) . ' 2>&1', $output, $return_var); - - if ($return_var === 0 && !empty($output)) - { - return trim($output[0]); - } - - // Fallback: common paths - $paths = array( - '/usr/bin/', - '/usr/local/bin/', - '/opt/bin/', - '/opt/local/bin/', - '/snap/bin/', - ); - - foreach ($paths as $path) - { - if (file_exists($path . $name)) - { - return $path . $name; - } - } - - return false; -} diff --git a/lightroom-companion/admin.php b/lightroom_companion/admin.php similarity index 71% rename from lightroom-companion/admin.php rename to lightroom_companion/admin.php index 9d7a4c9..0503485 100644 --- a/lightroom-companion/admin.php +++ b/lightroom_companion/admin.php @@ -11,8 +11,9 @@ $page['tab'] = isset($_GET['tab']) ? $_GET['tab'] : 'video'; $tabsheet = new tabsheet(); -$tabsheet->add('video', 'Video', get_admin_plugin_menu_link(dirname(__FILE__).'/admin.php') . '&tab=video'); -$tabsheet->add('server', 'Server', get_admin_plugin_menu_link(dirname(__FILE__).'/admin.php') . '&tab=server'); +$tabsheet->add('video', 'Video', get_admin_plugin_menu_link(dirname(__FILE__).'/admin.php') . '&tab=video'); +$tabsheet->add('server', 'Server', get_admin_plugin_menu_link(dirname(__FILE__).'/admin.php') . '&tab=server'); +$tabsheet->add('settings', 'Settings', get_admin_plugin_menu_link(dirname(__FILE__).'/admin.php') . '&tab=settings'); $tabsheet->select($page['tab']); $tabsheet->assign(); @@ -26,9 +27,43 @@ { check_pwg_token(); $dummy_service = null; - $result = companion_enable_video_support(array(), $dummy_service); - $action_status = $result['status']; - $action_message = $result['message']; + companion_enable_video_support(array(), $dummy_service); + redirect(get_admin_plugin_menu_link(dirname(__FILE__).'/admin.php') . '&tab=video'); +} + +if (isset($_POST['action']) && $_POST['action'] === 'disable_video_support') +{ + check_pwg_token(); + $dummy_service = null; + companion_disable_video_support(array(), $dummy_service); + redirect(get_admin_plugin_menu_link(dirname(__FILE__).'/admin.php') . '&tab=video'); +} + +if (isset($_POST['action']) && $_POST['action'] === 'save_settings') +{ + check_pwg_token(); + + $new_config = companion_get_all_config(); + + $max_size = (int)($_POST['thumb_max_size'] ?? 350); + $new_config['thumb_max_size'] = max(50, min(1280, $max_size)); + $new_config['thumb_no_upscale'] = isset($_POST['thumb_no_upscale']); + $new_config['film_strip'] = isset($_POST['film_strip']); + $new_config['overlay_video_icon'] = isset($_POST['overlay_video_icon']); + $new_config['overlay_video_pos'] = in_array(($_POST['overlay_video_pos'] ?? ''), array('bottom-right', 'bottom-left')) + ? $_POST['overlay_video_pos'] + : 'bottom-right'; + $new_config['overlay_play'] = isset($_POST['overlay_play']); + $play_size = (int)($_POST['overlay_play_size'] ?? 20); + $new_config['overlay_play_size'] = max(5, min(50, $play_size)); + $play_opacity = (int)($_POST['overlay_play_opacity'] ?? 70); + $new_config['overlay_play_opacity'] = max(10, min(100, $play_opacity)); + + conf_update_param('companion_config', json_encode($new_config)); + $conf['companion_config'] = json_encode($new_config); + + $action_status = 'ok'; + $action_message = 'Settings saved.'; } // ========================================================================= @@ -155,10 +190,21 @@ function companion_is_videojs($str) // ========================================================================= // Assign to template // ========================================================================= +// Read plugin version from main file header +$lrc_plugin_version = '?'; +$main_file = dirname(__FILE__) . '/main.inc.php'; +if (file_exists($main_file)) +{ + $header = file_get_contents($main_file, false, null, 0, 512); + if (preg_match('/Version:\s*([^\r\n]+)/', $header, $m)) + $lrc_plugin_version = trim($m[1]); +} + $template->assign(array( - 'LRC_ADMIN_URL' => get_admin_plugin_menu_link(dirname(__FILE__).'/admin.php'), - 'PWG_TOKEN' => get_pwg_token(), - 'LRC_TAB' => $page['tab'], + 'LRC_ADMIN_URL' => get_admin_plugin_menu_link(dirname(__FILE__).'/admin.php'), + 'PWG_TOKEN' => get_pwg_token(), + 'LRC_TAB' => $page['tab'], + 'LRC_PLUGIN_VERSION' => $lrc_plugin_version, // Action result 'LRC_ACTION_STATUS' => $action_status, @@ -190,6 +236,8 @@ function companion_is_videojs($str) // Piwigo 'LRC_PIWIGO_VER' => PHPWG_VERSION, + 'LRC_PUBLIC_THEME' => companion_get_public_theme(), + 'LRC_PARENT_THEME' => companion_get_parent_theme(), 'LRC_UPLOAD_ALL' => $upload_all, 'LRC_VIDEO_EXTS' => implode(', ', $found_video_exts), 'LRC_VIDEO_READY' => $video_ready, @@ -206,6 +254,12 @@ function companion_is_videojs($str) // OS 'LRC_OS' => PHP_OS, 'LRC_WEBSERVER' => $_SERVER['SERVER_SOFTWARE'] ?? 'unknown', + + // Settings + 'LRC_CFG' => companion_get_all_config(), + 'LRC_HAS_GD' => function_exists('imagecreatetruecolor'), + 'LRC_HAS_VIDEO_ICON' => file_exists(dirname(__FILE__) . '/assets/video-icon.png'), + 'LRC_COMPANION_BLOCK' => companion_has_video_block(), )); // Render template diff --git a/lightroom-companion/admin.tpl b/lightroom_companion/admin.tpl similarity index 60% rename from lightroom-companion/admin.tpl rename to lightroom_companion/admin.tpl index 99d4861..16af3fb 100644 --- a/lightroom-companion/admin.tpl +++ b/lightroom_companion/admin.tpl @@ -72,7 +72,7 @@
-

Lightroom Companion

+

Lightroom Companion v{$LRC_PLUGIN_VERSION}

{* Tabsheet natif Piwigo *} {include file='tabsheet.tpl'} @@ -143,21 +143,28 @@ - {if not $LRC_VIDEO_READY} - {if $LRC_CFG_WRITABLE} -
+ {if $LRC_CFG_WRITABLE} +
+ {if not $LRC_VIDEO_READY}

Adds upload_form_all_types = true and video extensions (mp4, m4v, ogg, ogv, webm) to local/config/config.inc.php.

-
- {else} -

Config file is not writable. Add manually to local/config/config.inc.php:

-
$conf['upload_form_all_types'] = true; + {elseif $LRC_COMPANION_BLOCK} +
+ + + +
+

Removes the Companion block from local/config/config.inc.php. Video uploads will no longer be allowed.

+ {/if} +
+ {elseif not $LRC_VIDEO_READY} +

Config file is not writable. Add manually to local/config/config.inc.php:

+
$conf['upload_form_all_types'] = true; $conf['file_ext'] = array_merge($conf['picture_ext'], array('mp4', 'm4v', 'ogg', 'ogv', 'webm'));
- {/if} {/if} {* --- VideoJS plugin --- *} @@ -276,6 +283,15 @@ $conf['file_ext'] = array_merge($conf['picture_ext'], array('mp4', 'm4v', 'ogg',
Piwigo Gallery
+ + + + ' + . ''; + if (!empty($conv)) + { + $html_tabs .= '' + . ''; + } + + // json_encode produces a safe JS string literal (handles quotes, newlines, etc.) + $js_cards = json_encode($html_cards); + $js_sidebar = json_encode($html_sidebar); + $js_tabs = json_encode($html_tabs); + + $js = <<scriptLoader->add_inline($js, array('jquery')); +} + function companion_inject_cards($content, $smarty) { $search = '{if isset($VTK_VIDEO_ORIG)}'; - // Already injected? Don't double-inject. if (strpos($content, $search) !== false) return $content; - $anchor = '
', $pos); - if ($end === false) return $content; - - $inject = ' + $video_dl = ' {if isset($VTK_VIDEO_ORIG)} -
-
{\'Video (original)\'|translate}
+
{\'lrc_video_original\'|translate}
{$VTK_VIDEO_ORIG}
+ {if $VTK_VIDEO_CONV}
-
{\'Video (converted)\'|translate}
+
{\'lrc_video_converted\'|translate}
{$VTK_VIDEO_CONV}
-
+ {/if} {/if}'; - return substr($content, 0, $end + 1) . $inject . substr($content, $end + 1); -} + // BDR cards: inject at the beginning of full_exif_data (EXIF panel, right side) + $anchor = '
', $pos); + if ($end !== false) + { + return substr($content, 0, $end + 1) . $video_dl . substr($content, $end + 1); + } + } -function companion_inject_sidebar($content, $smarty) -{ - $search = '{if isset($VTK_VIDEO_ORIG)}'; - if (strpos($content, $search) !== false) return $content; + // Fallback: inject at beginning of infopanel-right (if full_exif_data not found) + $anchor = '
', $pos); + if ($end !== false) + { + return substr($content, 0, $end + 1) . $video_dl . substr($content, $end + 1); + } + } + // Last fallback: inject at beginning of info-content $anchor = '
', $pos); if ($end === false) return $content; - $inject = ' + return substr($content, 0, $end + 1) . $video_dl . substr($content, $end + 1); +} + +function companion_inject_sidebar($content, $smarty) +{ + $search = '{if isset($VTK_VIDEO_ORIG)}'; + if (strpos($content, $search) !== false) return $content; + + $video_dt = ' {if isset($VTK_VIDEO_ORIG)} -
-
{\'Video (original)\'|translate}
+
{\'lrc_video_original\'|translate}
{$VTK_VIDEO_ORIG}
-
{\'Video (converted)\'|translate}
+ {if $VTK_VIDEO_CONV} +
{\'lrc_video_converted\'|translate}
{$VTK_VIDEO_CONV}
-
+ {/if} {/if}'; - return substr($content, 0, $end + 1) . $inject . substr($content, $end + 1); + // BDR sidebar: inject at beginning of metadata section + $anchor = '
', $pos); + if ($end !== false) + { + return substr($content, 0, $end + 1) . $video_dt . substr($content, $end + 1); + } + } + + // Fallback: inject after {$INFO_FILE} + $anchor = '{$INFO_FILE}'; + $pos = strpos($content, $anchor); + if ($pos !== false) + { + $dd_end = strpos($content, '', $pos); + if ($dd_end !== false) + { + $inject_pos = $dd_end + strlen(''); + return substr($content, 0, $inject_pos) . $video_dt . substr($content, $inject_pos); + } + } + + // Last fallback: inject at beginning of info-content + $anchor = '
', $pos); + if ($end === false) return $content; + + return substr($content, 0, $end + 1) . $video_dt . substr($content, $end + 1); } function companion_inject_default($content, $smarty) @@ -850,35 +960,89 @@ function companion_inject_default($content, $smarty) $search = '{if isset($VTK_VIDEO_ORIG)}'; if (strpos($content, $search) !== false) return $content; - // Piwigo default/elegant/smartpocket: inject inside
- $anchor = '
'; - $pos = strpos($content, $anchor); - if ($pos === false) return $content; - - $inject_pos = $pos + strlen($anchor); - $inject = ' {if isset($VTK_VIDEO_ORIG)}
-
{\'Video (original)\'|translate}
+
{\'lrc_video_original\'|translate}
{$VTK_VIDEO_ORIG}
+ {if $VTK_VIDEO_CONV}
-
{\'Video (converted)\'|translate}
+
{\'lrc_video_converted\'|translate}
{$VTK_VIDEO_CONV}
+ {/if} {/if}'; + // Piwigo default/elegant/smartpocket: inject inside
+ $anchor = '
'; + $pos = strpos($content, $anchor); + if ($pos === false) return $content; + + $inject_pos = $pos + strlen($anchor); return substr($content, 0, $inject_pos) . $inject . substr($content, $inject_pos); } +function companion_inject_tabs($content, $smarty) +{ + $search = '{if isset($VTK_VIDEO_ORIG)}'; + if (strpos($content, $search) !== false) return $content; + + $inject = ' +{if isset($VTK_VIDEO_ORIG)} +
+ + + + {if $VTK_VIDEO_CONV} + + + + + {/if} +{/if}'; + + // BDR tabs: inject inside
table, at beginning of
+ $anchor = '
', $pos); + if ($tbody !== false) + { + $inject_pos = $tbody + strlen('
'); + return substr($content, 0, $inject_pos) . $inject . substr($content, $inject_pos); + } + } + + // Fallback: inject inside tab_metadata panel + $anchor = '
', $pos); + if ($end !== false) + { + return substr($content, 0, $end + 1) . $inject . substr($content, $end + 1); + } + } + + return $content; +} + function companion_inject_auto($content, $smarty) { - // Try BDR cards anchor first - if (strpos($content, '
0 && $h > 0) $parts[] = $w . "\xc3\x97" . $h; + if ($w > 0 && $h > 0) $line1[] = $w . "\xc3\x97" . $h; $fps = (float)($row[$prefix . '_fps'] ?? 0); - if ($fps > 0) $parts[] = rtrim(rtrim(number_format($fps, 3, '.', ''), '0'), '.') . ' fps'; + if ($fps > 0) $line1[] = rtrim(rtrim(number_format($fps, 3, '.', ''), '0'), '.') . ' fps'; + + // Line 2: bitrate | codec | format | filesize + $line2 = array(); $kbps = (int)($row[$prefix . '_bitrate'] ?? 0); if ($kbps > 0) { - $parts[] = $kbps >= 1000 - ? '@' . number_format($kbps / 1000, 1) . ' Mbps' - : '@' . $kbps . ' kbps'; + $line2[] = $kbps >= 1000 + ? number_format($kbps / 1000, 1) . ' Mbps' + : $kbps . ' kbps'; } $codec = trim($row[$prefix . '_codec'] ?? ''); - if ($codec !== '') $parts[] = strtoupper($codec); + if ($codec !== '') $line2[] = strtoupper($codec); $fmt = trim($row[$prefix . '_format'] ?? ''); - if ($fmt !== '') $parts[] = strtolower($fmt); + if ($fmt !== '') $line2[] = strtolower($fmt); $bytes = (int)($row[$prefix . '_filesize'] ?? 0); if ($bytes > 0) { $mb = $bytes / (1024 * 1024); - $parts[] = '(' . ($mb >= 1 - ? number_format($mb, 0, ',', ' ') . ' Mo' - : number_format($bytes / 1024, 0, ',', ' ') . ' Ko') . ')'; + $line2[] = $mb >= 1 + ? number_format($mb, 0, ',', ' ') . ' Mo' + : number_format($bytes / 1024, 0, ',', ' ') . ' Ko'; } - return implode('  ', $parts); + $result = implode($sep, $line1); + if (!empty($line2)) + { + if (!empty($line1)) $result .= '
'; + $result .= implode($sep, $line2); + } + + return $result; } // ========================================================================= From fc79558cd402649285ac30947062cd15f82552c4 Mon Sep 17 00:00:00 2001 From: Gotcha26 Date: Thu, 19 Feb 2026 19:17:19 +0100 Subject: [PATCH 41/51] Cleaning-up --- Auto_update_documentation.md | 129 ----------------------------------- 1 file changed, 129 deletions(-) delete mode 100644 Auto_update_documentation.md 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 From bffe6550b8fb62b4744c3003d4e2e8d850d8e454 Mon Sep 17 00:00:00 2001 From: Gotcha26 Date: Thu, 5 Mar 2026 21:40:45 +0100 Subject: [PATCH 42/51] Last commit with "lightroom_companion" plugin. --- lightroom_companion.zip | Bin 0 -> 25617 bytes lightroom_companion/DESCRIPTION.txt | 6 + lightroom_companion/admin.php | 15 +- lightroom_companion/admin.tpl | 131 ++++++++---------- lightroom_companion/assets/admin.css | 125 +++++++++++++++++ .../language/en_UK/plugin.lang.php | 1 + .../language/fr_FR/plugin.lang.php | 1 + lightroom_companion/main.inc.php | 52 +++---- 8 files changed, 227 insertions(+), 104 deletions(-) create mode 100644 lightroom_companion.zip create mode 100644 lightroom_companion/DESCRIPTION.txt create mode 100644 lightroom_companion/assets/admin.css diff --git a/lightroom_companion.zip b/lightroom_companion.zip new file mode 100644 index 0000000000000000000000000000000000000000..cd68b99d5f215208f32c3061c18446401eb65ac6 GIT binary patch literal 25617 zcmcG#1F$FEwe{w#+vxh<>-T25Xa4# zeR7}7$lQDFwNhRR1QZ4E&oWZ1r2TK5|GxR>sI`TexwE64osFK6osGSLt%aQ}o&0~d zfB+N}DrrL|;PY3*002o;008d4urM&Tv9P7JH@9EZJh!D{LHN=uzt)!zW{WSup#^SA zrYzlT4rnaT>;Xcp+tB%`(a!cX2aO(~#|_wn^guH8*0?660rT`lAe1$nKD?h;VZwV( z`gBgQwS6%}9O!d9c^!SNG;(FgnOc1{_Z56684bsW2+Os<<_u2@Mh^*}+k;)(-rQK( zfH0(^_UTPrL6(imf`L5rMHyJnwCvbXdzB0~JPF~R4$ISu(g+5Gm9?e`5JxPR^Mh&xPRcdri zxS3kS_*V3|4ea10xxoqAHexF*`U4l#5}1G+9+>nB2touXi>@>X4U`FpAooWg(lU5L zl)QV0lACuFaI;fgbj~vB&`<6sA%UsV-yFNTh$Odg4o1FUMtTMNBURG#BviRtZgLbx z9=%bFKQZb|Fs{}j#1A+yMC8hd9RzTG|3tVURcUOc+wzQR{DDZDFGwp2#xlNWXzu5< zUpfg3V{b>=X_dM~tC64$-S`{p`qczwb6gyz6Ds6Lq9Az~!ZF7_%nb97+gng73^4~X z0`tj(83a{Fi|^|)_+8KkizY9L#E#)Vrfhh)Btpy8(J|Y%-MJ`0xv$X2NLhj3s)>4Z0c0}6p2Sd zGxLA|rg%_Vq$FX5RQahi9Swz|OEQuuqPao8jglZHeLG*hhxRPU@pJWO97K~bR}sZ6 z!wbd7wLoF>NEP9Y8P`HPOq02nolaeKY4a{wp^Rvb%8H$#xZ)Mq;3qLXb%NmCEONXu z4Av+#^2>E!TIgQ)rY||<-bxr$Mltt(f>5PQ<)XjcpXtMlYtn9eOTb0L&9g}uaz6Kf z6`?Z|kxWg@JD0|6D%E1|z)kwyqQbLhWQdV&m9VHX4_Jpuf-$0)_H?!MSdbKaG6-kD zi?zbg95~0Sk{0|_+d{s*sqhY#Lt3c0CZ|8X0M3rYdHRN_hk@AGo8$&$+$}a^R zQ9^j)1>T(&3d%yF>bV8d1FTlY-k><%yL~KF8|u0BIRrIZHeZMgrcQBc-e?%Hd7NTq zH3X5q?Trg0wbT4pqvDz)OuSmiwZqW7%Fdzw;o)8^jgQj_jeB~O4iAp7H>UgYV%^va z?lrAX=&sZ|YsPabul;F6#n^0n#c$Ly+S6OYS49sMol(vp_sFzcm+Xxpl$Jt4!Ye35 zVjv+meKr2V>VEPDuYy04p9s8p-c=B8u00K!QGsQAk4oeSoMvDU{> zz<{RKq6j9gTxu>YFbTk5l7mvXhnDGp`X`IxFs`jPf66gNpMFI}kNJPghDMCt@35+q z2^X7jFt)Ml>&3!vQgqmhh3QF2MNzi`3DGr>5XGagaP;n{kT7`eWwH}!FM`^WBe}T3 z7Y%5!kEqJ$Y!oUX5Ve+|?9e6_;XU)?>7mr!E`B;{y`qA<)L`}*m=&H-N_U={s@J&J zR7kOuzwucTAaDF(6Eo8qs}XhsA`w1@sly9E3(&?00+Fs!r-h%r%Obg9G5sBnjh9NK z*8v#NNqUEf+#;ed(yInqJhB=Y>2`2R_Vv#qd)op3mPnO)jQen*sv#QL=ZaL-5afv3 zG!+P_%WQ^rWY68@?blRRffi*E<;^PJR8HaJaS@q}uxz`Ew{rLuq$;ogiiv*&VuTy7 z!(WwIE{=j@DaV_fC`#}!RCkCVyUVj{LCLQDC=WLH%u#8)I$`m%N&lrPV?e9*mZW${^e2%tm~o)s0T?8jKXICe zXf;mSV+q;~nRDMGJoq10Ht7c2H(@0%o_HlGSPTq$MG^x?Dn<^g4hWHqLG~=NksrNz zA^hB(^`&WozF%!peVNQn^Sgub6FrD^ULNyOvGtU1ITx7I;J$h$Eo^mdetHUvjJ(RT zz)x~Az|*~A0d(+XDEops#Mlw&le#9JA}$gR^2IMl5UUBmR$w54npS#6=YJ4R=X)q4TsH7UDTl) zq?f^eC8?INeXC~z_6@RN*;xpePiKCqIbdVA6^M<^^lE@7U28Q9TMT_(iF#DiJyg6) zD+O5pmXSK$56rNxm{f#QvSC%Rg=)f1mQKnR6*c~Mh(A_>z%R~Q9I9eF!zT)cA9u7C zJM(sWYUUQGa|9)orAPTao|5b;5GwwvyikTWPyWdq-jClFt(QE@Vtf+S!X(zJz(!%| zOwAF~zt6j2s&N>?Ad}BQK@gkOhx!p4Wqy4^Z70!DAR(@366M2h4)u6qUc89JqKhs1 zmFq=d6=bsv#CpQ2FgxonJf{50*WbOtZFe>QA?Z5tac-3yeNYIiD*m?lxcW=bC8GqM z)XBT-j5_e6f^rr$K3tlkzj20z!qWs_W=6=7Z1fq=U05WNkekKGYLguiGa95iynQ*@ z5g`(JP9yzYWJ4e0q(ZwJ4+f&~gxC8vcQvu>hjy1SqOK5bdo`U1*Y<%OSK78uk`J=q zyR2UpTr}eg(qdfv5S#z(?&(Xkj{`%wi|JSY{7xR( zn?CD&pjN?e5(@Q;?`J=vGOX#E){25? zZ_Bx|Vq$eVx0=>VQY1HZ8ar<)W7Y2hvnt3UbPI~XSxOTdWH(QyT{ODce@TmlHTE{t zur2Q<`#7vAE}f0L0L6A^#>s6SIkxk((H6d^cmcHGn-AI-F047lM06~6BF*!%5?A zs$Z+@VKNfXvrN|c%Xu}A6b$*-CIdSCQbRRB-$&?CChE9Ft4uzZA)iC@mLi<$6q zp3v2az7;f0oxhS*QEka7ZLgU&g{g6NQcv+J(_Zf)hs}gxRPsH|Y-+NTwmJKqz}!^a z{9MS%?($jJxg>^ar!vZhS^5YjV|K1Prr!#8p7QQ^q^n=0F)JOTt@ucnB@=g4OQ3nM zR40e+GRXAlxaKTgN$+`3%XStoE6S8rM(y@&fOwy9Lx2Po(4y9Q@DtM}YL0=KH$8Wi zbV>3`c6GVztthPd<{CfLqSJaYbAJAQ786a9?&o|ErJHkmWe@Q$$ww99(6jdT3Fb4C z8FDK0;H2eoQ>D5J;8m3G23oDKE#+YmEXC?ht<>MxxeT2LbtxbHEMXN5I2X)eP62Ru z@{xUC!2cJ{`3D)@h;CkqK?4A6!v7V{akjU<#96i7XT9m-{|-=DPw7%WUdc7z1Ire6 z*nAVbxUf~A23;*MVQHtFmDpIoU${Prb8$au@D^|AvI#gLOb~Xooq`E#oC>P9^E{X= z{iRICWF{@WT(10!_Y*YVYf=n!G2!7sS=oD+#GL^3;fr4487NMN64Ek+2J5LhS?BML4(i3Vn`<8YE zioP@2)1tj*qhDqn2Ew&YFQLXWM{1eyA*b?G{Ra*VXjomDDtX`iez=Q!o%Au>>q>Sa<-YmL>uT0T?L8-ldf%f*$tvEuPi8wD%-9^>IKcN5)XO)oioTT6*O;L!SH4 zbM!UlHdD8m_w-BdEzkZsa`z3d%dyFj)*6J?T-Hy_t+Mzy12tG==HdA7;N%fV>X@Cu z*ZLpyg6j4y1Cv3u^%s%58Qs3~tT7a~I?JMIHLwG--q+l9EaQ@~ffuy?saQ4u#w!oj zRl99gEuF_|M<(;fcK=Kk5HCQQkw1)6S&TYSBu<`7;M-AWUMW>elJ6L5QtuPfYwZ)Q>c$svI}Obq1&&~*jyAW;b`wYtI07p%@n0nf0ZVIibVav?!l>R7vDAqcfu(ZSiJ!X#H{ToAgWb`hn?#=tI z@QBv7_bn>?zVdSnDZVu1w3TERX)K%x*5GG=s1kScjn;Iar~gf1LP#wNi0KuHu^cxP zkr|3~WS6osu$uKQ3H=jM{RtUw2mG%G;cewQq;Tb$Bxc@%QvCtB^CK!F=I#RdtQ?Gq zrqcS^Da6nb?UsP(OCyQeEdSvOXne5Wvq5@-iTW&*u*sAeUaiUGcJG(E`;s<|>roqt z190ZPTpeI0kdOJ=`pGE_IS_gwp${ijjqjZK|xxS?(UjU zQEAIEkDD^keO6vBFww}#GqAz?28PzT701ZSEeSEB3n2yh$Kdj*2YG#c#`_iXXAN@) z(0R2}$s{ua^3uaWE3(ypXm2eGKsAbCdyw}SGKp5ej( z6bYYhGA=9c8mF|Zz`$m9S;Bja(D=mk+O-mAiIO^zD#Oa=vz69!5Q@X65?I6d**Zu? zRrHFUwexn)7Ooz%S2|xGwUn^heSVTw+#-asGRrt1?}!T{0hc;a0H!}!r_Op=!8TdR ztA}APdi!5wMd5t)BhNco3r@!3n#Yvf_%CmPz%MFj8~{|X7(*g|AO9H4c1*xKMge|| z?E=ehvdQPBHcO-J9@N>Z3?s&SkuC%T^8k;f3HqEli$yzu(`YChi%t{^mmfmmb7QE5gt&Wmpd3>}nKNiVT1p`dT4ojY zcq7Ub;J%lv?KoP8kVa^S9LXrXKN}b%MemKcHhxUTDO+>)#DT3sZ5$2~m6$nKC)h|U z8puE==}@fDSXfWYLW!~}V*N(_i6mvgzZBT=(q(@ZtX%! z3dR zGd{;7SHzoyTr`ABOn$CDH>h2nnoG9a0#U%V#HBIYJIbpl)NaD6zH*_HQf<$fZSu(x z{*_u?u&xyL=?a&|lp*f~B9=70Jm4bDIyeKp@LjKG%6Uss;i+U-y4pxF)Uo=4cZ|{n z0?XNT5U(1!vl zv7!Tag&py=qo)R)8EQVCRv8aAnSyf1EmnfTi4VoUbsv(BOJ~&uYrOSc2_#|Cw+Z0^ zC=v7*SUzW3C}`0rwdWU+U#EA9&1iXqV-C_wiJo1erbIuA~(aA z9Hm!lpCjm z8y38w+o-koLLFU5W7TQ3FM0~+A$HKCNyy~gB`_EZIg2fKzk5N&rziFN+5P?W=w{Yu z?XH!WfluUV#5nhW=NU^1ZCa(S$|^_-b*okU_#LTOsXEWS&2{4F$!_hE1)_ZShB0m- zD0d5LEs#@P^09)!|MB{5TZ!#CHi^!iU&rj(at~=BsATCytc-le8%I=yUgOH%EM=%Lzp>srHzuZma>^ znt0v%H}z8`_MwU4d>87OcE*gOMa-CVZKTwJYOd`%{pHdFzLko$6|+h?)-PgNmWZM$ zU0)Ea{0tQ^CIXkkAonLcrW224*6{hC)h^xfp(-xjRgVuZ)5U>~&HItF_>;~rq0B{4 z>n$j5Em*xez!n1Mqrl=Cvyup_*-{r-EwuSiBWy=(S_aY8$(Xhz1lz8pP(rblD4gxc2?vX)vJWo(RzW6~(D% z{^Th?nWf_$0{h}i!x^SpeUS8P&I!%sdl+LgsIIkD*WCXtY7_U{r8!J8Di##|!DySl zn!{(7C<%Y=B$ANKqLUoB6Re``=Fli_yci<|Ig!JaV}9Y8fUlK9qgwhI2UI23O`RWZ zV(@{@Y0qP0o%kBCK#&J5lPwC}m895?Fhk^$or@!n54>7KIr?iIO4Xyej_l8*pT7Hh z?|5-iYWdpeSyHOO9rmwQZ6zC+-nwkASEjbfU-3J|n96Z~mT%r+-tU2$35eZ3BPZP7 zSXAC#Z_Z2lNAM5MG+xTXr`ca9KU!gB**MJzmk{&GJ@#i;96`u2m5s-8v~^_o1Jiz#tMCF>K!<7^+}eN@iRqk~|0%^AN{ zI@1r3|H!}rfXe@go5228OoNk?iL=xHjiUI+(Bf<*5*7>q_&@*vNdDJ`|ISnxIXN|{ zX~oXdqkM`P!lOY%5OFhSG$$*I6TBS2LlP45ZVbrC;Bq-?ujLCGB2oh{^~$P4F2`}_ zz#Rs;iq1PsKlc?QN@(P%HIPT>E9Px)cNb0zGc+7E3VObuiwV%Y z%qDfXU2OqLz|cJ?)&-gq%PUT4v>z|{8W_;IKA0AY1EJfe?7{AX)hLp3h?RD@A*_+h zdkAVGD%ccJB0VP{%WU&y%c`DK3RpzUXrScE;jq#_3M{o<=HgXqOyo(4ZPl4)6Rg4id^ny7IkQkc8wr8^AR0gP8E-p z^+g(6!$18vqK$46I@Ij7k@Xm=Kq^RHfvDDW>Zv7`EuKL@Kri7Omxv!Zu>@Z=2}BA> zU!e!;#i?k-!BBclyYOC9%e1kzR68eHNqqXETH_w2U_Ob7p^=wz)(tYWyb&EQm;1(9)baepZC*YejaBS-&P(Wn4hDHLzY5&`cun?vdPYw|Ircvn5g^x^bno7Fd?zW+xoCpLuwRa)H znNss%T9%%^Sh9C!$(irN$Ms&e$+2lS_yKjw zVv)0G>R}PM*N6!zI|j8;$ye>E)XKMTsf@c-#^hyWQ!v=N)BCVQO^UP55#MvA$lAx_ z`O7lrwrNxjW6!C(U5iZr9<2n16&X<*5eZ_Z!m-FwS)(^qKfgh=uGp4p1>s7y7es*w zr6{VDIo1yOBIUlEPast;z^6qnCqco5DhG!K{fjO4F*2xi4Fg5YxPj~Pwud=}RXPXq zVglxU#u$|r$7m)>vX3^CJQ91$TDTmVdsbZEZRe_^^{X?aclw)BTtQyP<*DraJO_9Q0N=xMb~FyIANUY>d+$+Mzy@&SjE)eJ)blF!shxr$x`8~%8>m!pITCfv&sMdIs$+{JIb%Wp^-#a}%Xab3l9V8J9 z?~xscSrq0EgFg24hUt-XUOlSwd2ib=l@$c=OEGbK|G^K=R>aYsXahAhCWGW(EIp3xc^?2_GVL%$$0fxD8gS5&v#ovJc_zpt=rY)7U z73P%5OA&Qac)6`plMI|5HRLp^oc#fj|Vk(J4W&Z6oU-s)tF9 zQ&DKiM+dpGy($dh+Qt0#VYFxWUSJH9~uxo0!Q%Cx#ID0Ok@d})?&kJfXYzZ#N6dM86 zBr}o0Or`2O&|Gp-F4e+rT~gU?b(S7brCcYP)+)^)J^i!y%%C;PdHfD_WTLh{aXz5` zcq9OTr}O_v+y5&P-rB&{%*DXWLXYrY5#}DpLP80My@80RXSR z*B+YO%#!`v|EE#^ZU1kQf&Xa$3tM9o_kZ*N+He<&sVMkK!37It;%?#mclRIn1mfQ& z)>l2~Gyo0&9HIgMjQ@Ln*;~7q{S_+wfX;;s&^)5yaF2X(3+v%Ynhr zdK(JBf)OJBCIPS#IM$_`cTsNsW;?y^NJDBH>=a?bemzS}`)%DE8(h!lDn3sWyM!B` zdiYM*+-9$mkW*6bBjcIM(P}sM=Wgk$9=!)p$pk6X;eAf8e;zyD^?5x2!%67#m?!i1 zVC8quX;2Adk<^JS= zL)AcG4vH$OvI}baScN$&7}YqWWwBsXlMt5xgNAbXJtHC1R=i^}a_K(fA6ZFqxR`ht zleg+C7eDQR-1@!Ow=>_5D#I6`sh}U3ibO;60xlk6+Mtw2q61V*jSLP9>;Ov414ua-Guas}38 z!h2-t_aUx8BBeSbY-2^~RzxNOlnDq$kl=`Sf6`xBKq7jImKesb=VJ`!kti;WR8>s# z3y$YM3CSzS!DuAp@584zJ`Xr)uwIl|BUZmQygy#X4+6TSDMUx83!Scdu`_l-t8dJC zqfWB_d5GC{yz~2@V1eTIsvsUImgn(I9fo>sA)vRc%1%467 z&7)UI5ohhD^?RsFa(Lxsb93KUkl0U1@cYCL0S=B!In7(9xO|x&8O?m_=e+9ImUij3 zs7zldVLBBYSgj+a^PfJ9JJ?CXKNmJ7$*h4CHYHXhq54pLY-H2);1kHnMV2>nmw$vG zmt?o=d5%{vT6B&JVd9^1UI(0yM&j1+d{0b=TQSp3&4?bU($5A;8ZHnA$H=GBTMTb`0}vWB84MMdRA0i6@2)HRgMbW}i8HMesKyuh1v>$A~=TvB!e zanjA^=7|9H2)@_<9M>3V&o}5eZ|E+D##no3#D&Z~Te30^gwO=Gqb9ZeE=7s5jM-Xq zMK)$OaR$)PAZ@gJY`PudUdj!nvnVTW=hD}&L4nRkGPu#CL0dci>BxJ{=eP>)}W$cQC*P)(CO3Qi3&DMu(JhQna_IFWU0}?gaCd1q)@uNhVc1}-DPW-g8FIY zo593Hxf$3rEQTn$TvI@?T=Qh(fP%o6^Z}{Y_lWXSHj=*}>RI!6s=sZy z&AnKI?FW1|cLeQcZCpy|)tqBB=rK$C&^b8M80OpKF0>|u_K9|{T!bp{?J13t%acb;=1{7+Ja+wi$yvP#pt3(ND#4!{HGFm zSpPJp7b!(HC<2qt@L0B(gh&wuG%y{VeSN;StjEP2o!lDw-5MvTA5xF0wpVPKU>G(M z=6JY4CbnC&%9|U}hzL_ICjgCMjgJ@Ua)SI~MjZxg7`{Zq?R9YiIyg;a zd|0^I^tdoyt5Om6>_TD@wu>Hm3PS>A@zj`gg`S301<}^*ihVmgaS9#(fxYL|N77tZ)lvCX3=Y zjZ7wBe_KN=nsA!$e*hC97H}PY?bOUw)>_ekS}k6(#UlLFibkeyZ;l+N%B-hEWZWl% zp{uH(!EC>sq9dp)e|n01cYdk5Cb^m5e5Z;?23z6`#&R9&eIT~0Z;TK(#SH85vnxDa zB4r83nzLJ}?K+MiH$-;qbvdjyss6N3UJnLQzBD454}yLbtnLO_*RdL+J^emV`zfSp zuYh&b*#nf4l)24)E|Gt8y(C=WBC^SL3Qy>9b&_FVeC{K_&Tgb(O9%{{v9w^1qeiyf z|JCm&`M6w<@hz5YG%}APFWDR2XPlrRKzkgJmdC|BQ1*)s}&ZWE%7!^%=u67pVjY z`m&U-)5Me3)Tp`$NoRrK8wp@e+iuXu#XYv5&v#~rcvwE6X`yYI%x{`>nAB0N;0@0; z`Ht$h1wHfzgZy+b(cEg|d>W>U#%$DUVD^;5+wx4ezbhgwz=ogm^%C;LF8_u}0Sv|u zt0w{l2|V?nr0TkH8X$Q{Gz2tzuoC3Gt8n9AJaedvJUQ>dLky<5R9 z?mW@z?5h+FCdWWcAzWq8_ zu~K0B1@sqPD5Z|feBQw76aJhF9@Q_|Oci|g7hOU3FWFT@ob8QP=Jo?)i9jhQ@&Ea; z35IQ1eNGAzw3o7Am8;3I7|K@JgM00L^h)G1?!g=$8+0DOa%%64~nDYt0* znVmR-P{Lws%gQ*cbFkmaCJz||x)vm`QXI3Lo>d1Gboq|H;g4L7@$Puc&93y-!*@*1uhcptQMKA z)ZCKP25lXFK2|j@oE}>!D&N9)eF9a+)0$+0At?_lkFj*Tj8!UfnhahF4g>#52+CwB z*P5nHp=;LHd5^(5uRd>t1udAj?V1;oOTlZ;7V9j)(YlCX={ z1f$)JOg`Qgs&#O&Q49ZTI){@R6K`uLyTYxXgqJ>y;DnC)_G1fUx)v~9gqzIaT)UZV z@+V^E;(?;>^r1XKA^%gG(5rmcG{ou8jPs;E5s$Xq>_makb|BB6>mUkk$sH*4k((jf z@qUrlA{8pRH|V0x&*3HXW-3FcxI9qllRxM&0Xa+pl1{q>a8bhUI-JQf4A=^{hOF+r zk>c@(J=C_y&qq<8H_f(702<3B?c1)?ZT!XW0$G5AJs(#+=l+=O_8VIOw?Q0hYMY3}LismDnS+i}wCyxe21-&^^Q z`9b!`)lYr2tGa!2?$LRYjC!t7YrFvkB)-oDF@wzBINat%pW=?s2PLv++7oNEhq z*5hDNiLpD05j?S;e1AS5GsJ@)=FZ>5BN2SRu0POy zq`;kC2VpWDtX~5~Oal&7+!1g_DdX+#+1O9!!MlC>c%&R|n;7fWQzRBGkN#zXqU$0M zW*0hZZ)bI%qL%A>rk@~}=p5FSbr2*$P|MN=a}rbN-EiXeW3+1cah_?RkwNAMwzhyA z-=l7P{crl|Lig;;y2?5}=mu`-U%qFrTk~=}p9}OjA`a}<9`IXUH@6*Mr=z27-#a@W zJhhWZ%4REfThQR>{*!^+@MiZPn6+I|>0QUY7Z)C`Sh2)KBa1+mQRvjs_Yav5E;l<6 zwgQNunEb?Y%3XZ0d$&XwAT9>tox3jn23SI<|4?d6#nj z56se9TkI1)d*@D^ybaH#3f>7oEOrEysK61W{8h;dDo6V|v{fRGL<}-_3t#NHxRa9mot@^Z81Y zb@LO~w-iinaJa+I{(8p9>wSXCqEWXL@Va|iKa9|zd92^uAy(QgCrXS^wV|sOZ_(oR z8pj;mHyCXks3mt1*rsV$9dyvHf4;I&C@}K0;`DN`dqkXP#Jj%o zT|SlT04%z|60xod1e@QyUPqXtm`H-pxIGmYJf@PkYl=Bjgu}hm0c+ytPU(&)KJB)L z87urS?ymP7J%(V%M1yUDU1z#1E(5o16iKdb65+iwVsl8Jnvx{Ok57viL@3AR^ywt# z+8H>YG09Aa8B$M9lH>{J0pOI!+Eo>-{7NJ_?-OuSUS=Y`Ke-@ip2-TJ)V(1M+y*nD zf#vc~dimuB$t=pK{9Ed3rMuK^FiaOeT(VFy&xK~`R>);cwIO+GKyMdAY@xEY^zxIa zC*=A)uuOL@^WOAAQL%v7`Kp1LaZ&_hO=nDAW)Vo62$@lwEqVv5kT z(=m{G=ZFtgYEo+ zxE-ky@t#d4)@ik|rLgwsLb$tB2`kUf1H-;CX&EXhG>T{_J$?KfzaH8kW~jZ+$->ft zAD^E!NugC;c|y)7<}y|P}=5`>E1b) z3`b9I@uqHnpWHlLoE-SpA5H1gr|G^kzdb|UR8)}#E1VDNyQ6V0*^Kqj1kp?E*azXw z7a~1j^@$91lr@1-u!Y4B{*KpyNE1YvM(6N5YFI`~-WF+9L{RG(Jy6;s#YN1!J%Fk4`X=_sx5n@p`6x7w7g^0EgvZ z6EN5!>JCq;T>1STJ530G1PQ_ugn4Ww`|$ZA?3yvd#N)u3;~t}4yYa{Kc^v^EFP+ho z!-}7LEE(!UQJG!N-0<0tS7(bn#t z^b&Tyy?WZ2m6abn8$sL@Q>2ZsseO{MVrd>~#{ATqsvgHL5DihIDBy04G+r<4SULUG za2@Dur1hO=?GESG$-6M9|&jbWAiw#kOE*JdY^*Dh{D5kPon{DGS$3gB|7lKuI?Vj zH)SP=>67BTyN%UZ;^ITyi73)bh@@%B=|l4i6YsPF)l*za_Vxin=nNwYJkn&Xb2dfs z7VncsbOubBUCt4DqwW)bPM}JC581W=I}G1YuWo{FLE|Gzq}Zg;>G{qauW7x`h^92Y zh=UxBn&xvf#f!2E-L4wiGLHvj`Ay-~e7-Ft?KSkAc_>!OKmzOYyZPgJy|6(VJ2xYt zsv^(D&jDy5Gc}?*%-L@ptQi=Sdu_+Yd3y*| z9Q%CKL3a_;jzMG@X0dUyQ;~_LqvU^}=ISs)Ud+Nu^^e2wzMrOLw>Z9Eb{^u{3de?J zg$tnbf94j?_${$-<;Zo}||5p+)R)`%PXH9=|ZbBn46| z+LvSv@vS`89t6ugLxsa`mNE4G*6ss{ANCP9u72fq=Fe6KgxkU$5_3U--hsL0=hAER zW^n{HW5)G%^=U_xIp-OeIO$S^>C~EX7gI$&h_=s6Gdt*1rX_`MMo@#NU2mFBWl!K41G=tBAwSahMAbGcP$a*0L=|vJ4>?Vz zgaWmjvCr0z1+!P0cm(P4oNfH6C>wOrvgAtTaQkFtVqjK^SKh`}nLod!Lv;?~zL^V( z88d@aCi+)Q4?h%$@PNy^^|*_+6wbzNt!j5iD-r}#QumR#t013G#$$UtJ*!Q6EFg zq!8B`94&5*5WZxN3EJA!k|?am^ohQdUqH4_;ztqojMAL;R@lAajP&b}4I z`_ty(ht@;EtcW%HSqFfe(cAOkf=~VU?xAz$wsFisIA*$;8$}gC^}7SbJ;?kD9Kmbd zqHp}(RNzL;06PUc$h&ol+I)ZwL@&HbVWHttCAQq4Gn^6aby%jdVVxGvfFk@B=O~C4 zPN<@`u*E4j1zzFFYEtWPfxrPn56M}&qI3@p1>Zlz%%Z#aMwyhb~Kr>wV}*0fl4`86|_cB z-n~ri4=<&>tVb)6SAJu5?_AI=@cC&$lPGO~`b*@*1LHmUr^24(5R{<`l#j8a9 zyvcUl;pR#}N7%y#nrk%u%+}29XkJL}o5&)sh5?_9FAu-__xrhysj*x!7H{2T<*pLM zINRNBt_Z6Rx!m7h5Y9Q?U%L9|g+hxI(8<>0OoedKt_C8PmWlE!Js1>UYKNx0ejCeChd z#g?vo1`8Spu+@>O4%Ofvq9M&vkM3*}402dujx97-ps+t~-ZM9NYJ)KP;vLF{B#hhn zpJC@GR`~0!8b4{O=q_UOla31>o7r0Q&TMRZzaN~OtbVUw`tst}VqT&OlizPxCQ+)^ zhv7N?_`Je0f*i21V7xSCxjmee(Li>2b%8yITu`obYAop|svKx!;tKjxg>US#ZD02Fh`|;Q?*AuTlO}%x|wrnNR^4$iz$gMRiY)d?X?`$98x2WO zRUe&2bFo@2vf8R8Yyy?V@9I?%FnVK1J)c(-JX6ZWG)W3Ws`KVlbSSNId?v^Mhhn-# zL3&%8DKjU%g%d>qELD-MAs70%DwTEQ4qy>$y|I!;){;>`pLGc<@)f{pjpqTl`3RGB z*&GhNhbm`JSp+%~_MG9e$ub1?B)bUrO=jOI@2gOD3ok)f`KLhrMZ5#-N4GCWldRR0KafJ@<0JD*i9*li6 z%G?27!P-oQq*ZTf;>wT}$Io!qyL7fe2nyX~**oO$Dr43HmUjyO=loELQ}WW0-Qv*` z!PGyR#`Wu(Bi5VM=$ZZOiuda^D<8fY8y^uU*?KJT+E@5U4SyvTMa%gN@F7e_`1e2T zqBbXnSdRo}N$cdeDsfRqhGUVag_T8J0yRPO9!R4EFl2Bkd78nDgshxGij5-rnyhDP zR-^KqfD$$Pck279aSvKy{z{#JK~9VZ;=oVBW1etvM#S5S_LFGl%_pdW4e?k!YUx$r zjhJ5aQqENiD-VGLb%fOBTO(&36lL4CadPPeBn1Q(kd*F{ zZlt@r8wp8SxZV&h6i5(EOTk9-Ie?`dB;`5rJCXUhM zw7hx-O0`LU&4Uj}e{+8-gA_Et+C!a_ilpV&2*ZCMxGGl98Y8BEVqY3B(b z0eyYtL>w)Elk>GSCslESD>WdFe}hCRKI+vw3#o2N@P0bB zgl>U^Kbj3LaF6QsY{kG&-_0{>t?DFaMknPZodoYpKxv8R76K>Oq3Y6WE2%u39J0B|I^5 z1}FQGeDPf8xveWGj+GOWm?TZDGmOJ}il|LKVj_F-B;v8lNJoTJJl_!G6*o{z49^lX zlM*6b5e)m9D6s04rBGX*TOAnXTBu3?cA!&zv!>w85;4?`(sJUXGUsy@b6_zFc_WR+ z>RUY_s5D?FE!PsR(5s%ZIc9y_PZM#-&jaIetK>=PR|*GWVgX#_iYEW1!kY8u`q{O7 zBDsla=V<&H++LCEctmc0pO91=TQ|Q(<2svFAGTa5WMkwQynEpH=*X!x=BaM%^&};4 z=gGPVQNV@>LEHE{+JH4uuFtG75wB7boQ;K7R7RrEOp#t}uuN`69F5mMSJ8~&Gmuub z6ocS4`3S6wk0nXMN#pFY(L(4<=~*5jtXdc>8XykIfqiMNHzujM8HYe_vO0@n8|%fE zS(>jfCS{lC^kW*fm~1pk)Of@TtnjAE^{CUZ3o+}vCWe(}SMglOlX(=6^X4Qc6a8%w ztm~TXtBMM49u{vPCm{%-@x=V7r7kM#MD*;(#2nUH2OR&b(|kqcfl|J~%B(jp^rJz- z8`F`7-TgUP%mbcY1c(d>&mzA&v?hO8%#&v}$Zr_dmuts-`z3^9$&>C2$pUA9^` z?o*LTE0X#Q7(@lq6Cu7PZ_kxC8Kf0#PyWqH=t ze8dbf$%>4QV;F_-kD`Kj{@)WB_cM76!&4>%I+8+(MV~iZZ+R@TN7WQX$Q!GWN@F9p zq$9wD$h__*-ByuPX?b5v2DCD{wgV`v5fQw8ta{)Men?uEH`!=p7Rt?2%k&Jb#lMF_ z9XP^;N@r;=4mvFrVQ)zi`NJNY z7rVr!&slSk2NCd{>LECW6>5X~;U_T!v!e}LX5>%G1Z^rpCwNxNEr_q8JBGg55q72O znT?3f2C+hOzo;5r5w{r39@^SMDzMRZXIaoMu$p|xQYJP*<3!(G0N=j6^OpK%XX{Z` z3ZLBo+vEYM1SOz7i=O8T_$W}q zShHL?Upo|`GNqD-%U=siackazb=RjZV7H5K5!=bIKAX7_BM%_s5AN7>%MzQC)l)VJ zZsoc(pif;?o}Wh<1V|C14>3)z2WId!eYw;$vGd{?wi60-ju-R2KPq8MJj%vVUj2X(D}(zf=}L7E%G=v_TC+=q=xi@C!kdv^i0ceBbQOAHg}PPwb#F?&KI zCG_Q=P;bVvKb81D6tC+VT~pS6$-q_1B) z>TXgaN~Yw>rhggC_G;8N0t=tMXu1Zdjo85;UBw{|mJq~MmT2y-!|k@;95F)$SvL)K zi%^vFluHvSM1G}zrnoT5*0T)9RlM}XiY3PxpqSec54R+deQnz>GDHxGkaA)ip_9P@ z8?UKp^ZiJ^2E#%TbIW~fLA||ct+D-38#cqXID<=2ObZc1$+7vM=fYA^D9_!krptIb zrJb#7oLES)WT|NhUjTO8m`!Vm6wSxC3$*BPaC!p8ig_7=OBW(XxgODdK@}XD1?MKb zdl)p{v43O)qou{TEJ#Q>Vk(5tw&kY8Q-r$F2<5n>!Zg_IS=nzL!9!s76bY3$79kRq z>ZBW?yGSV}AOtT}riY|K4WN9NjZD+CAnI)0PT&HOgmv;_sxUlS^ zapp=8KOo0_=qE$2ueNZF3DsG0u+hTzRkn_`;$x`y_jzG|T{0{ynmF8CafB;bT)0m& z#h{fqSAMAe)~r7?Obj0TgTfa;vi0k3bn47RBCPw7Ho@_;IWzfi zTQ5fq(bM6|Sb9$O@(Tj%fj)3@`q={^PCi0q5^qODo36-7+iKno8zeUYMmK1?4(xD5Oi7ZLC32*AIhtb+TFNa;^ubNjU(0A9qJ#X0}?17 zeWH<1ze8alhFF1S2nkLOV>Y~KXq_@Zv`G?d}n;UXLR)vXkqPIxMg%4wXj$e z2zb(vFX2gL2`zz52+Zq>PgxI9xQ@#F9ge@Bwaj$eh`xG3%$$<~m@)CId zMejov?HDQ@(119p8Y`)S{*BRRanb2ft#Sx)m1v}ncyymbZgox#*dE7vIZD;5Bm+x< z%#Asm*~E{edp|#^+2^E4hu02)z*Wcnf|LC`%e38Cm)|E7sRc|-Zp1IUtankC@aW-| zrVB5tp(^#+GI}YqqNa&sYd_b7{8}!NNS0zQ)^UfdCn@I6&{8i0O28Ru00W=b&x57z zVbZp%O`=C?xvPN`>x^hja-MoUm=5VadAZZMWe>2zRbWoe; zGlq>fkr&fG1aI|{9QRnNph(BhKG-j%j|G#ta4UA8&YEj9tPj7pX+Z@%MqCZMI1S+( zijrk|d+NmeHGgA!(JY^y$10Yb->yXI+BtVep(-vuouJg#48 z@!q20D>@0?)S7C4ikT*PbQm?IupBlO!v{)cmT68w2brO=pZKxz&3K}2VKr6KT}4VH zLf(7q&7c8@Gbkf%ZHL^z!#p&v6JGnwBQX&^5;m0=thK0I!9&S-j$Pc~_QArmb$Ncn z9LcG*o18mtds$Y8ifM~y0}wB0JraYeK)pOtS&;Wt|yN& zX2nLf>*)agRLqlD&CPj**gT^f!CCAa zyukvP@8Rfx;bAPt?^uCS`MQN~mDu-l;k%|tEyq>aW^@UStzTD|+=s7-h;-AdY2JKqy|Y9&m!@@eFH#~dBE&iquZtn>=^~_@ zPaQ+qRH?B9x3&gxvIcS}+B#)UGq*aRS+>MWY#!{Q_=T2{_DsLAHYKY9d;rqbIhW31jv^Ae1u#_M-lM9?($>~j*H zY1HPQ@##0tb4@kRLdgnq67k>WC3j;R;UHN(d>%rC^!D2dXfEz>R^Yj!30aJj>iJx! zBd*R`&(P~~Q)>OfDq%~L5$malQCLT$+7r3F1f&S&@Lru!2$tQ)t3l|reP3Wncx3C zg^~E;X9dCv{L0Rd$s!XlMO4yF4}p*+L2vWr%fXVqy!L`Oy&6#v=Z33~f#@mRUy?_G zihIwwXYFf2^Ngu}Cc~6(^(I(h1UUILfvsCG59f>FT!>7n^cbgU5nplPn$31MF?FU% zmxe=h8Yi?Ffg4SX&-(h>Y&-3g&sRJ42ld_Y^&&v&6mZN836KcouUqun&Ves%{VB*+ z0j#09jNy9=t4K&HmuFxMkS-zoE8@hd5YBLl09!l*;F_ZARW1PCD95~7=U)q$Q^m|HY-cA)<4%i*COjc)B` z_5_($XJgw7?c{wA_XuqMPZs@V=vSpfY^6A={N>aFsP=Vr(Q|$L4Fj;Hn2sz1?e8NG z_x9I7W?ljIt}W(=p1vSh)?Jna`0Oki+Q!06oK;g(L`atAgWWVI9PK9x>R$qz&*ewa z2;sho%4#n$BjiJr`t%q9Z#LgvM*>GxytWmMlgl|~lB3ivBY5yYwHWd%qI|N~SQ+vq z>%|eS!KQ2!w$Q4BhP;euV?K$^)M6r(2Jx94&97}>*;o+aYN>+?z(M2#xlRIHplTrt z_mc29E>cmom%=%x8kSZ^hKDRe*A!K1hzlQ<|7^4afJ8-k9J<1iE?r86)r;no`e;pD+`|A z>q5HkNXhe4XaM_pc^^hp~Dni3HEkp_;n2E*l^k(Q1R+?*<7B^A{3X|_%* za{#X6+XDLD$H9>-Sn3Hz$u(suT&qHf*-hQ56XjqX7mp3W_k=U*;@t+6W~qhQiin^Bjtn0Ia%r z1Xw~OiH)P$LMap|P!7mwDhlH1`rtBH^pWY3evmPFk%K^Jc_dxSK@4`Rus-jGa2tDuP&e5={ihoYE#3JW4$( z<-UQYR4v$mpdC*;SnfiW7>dX`|W++M7ZrS zqPBw{d~@rSG?wuQ2AZ0{Yb)HcbihPv7=Od(Wbw2%jABGuX^l>@L=}TWuZPD{3!O@> zrUrJ|!S#moYM1;@Og}z6ZN}*JcX~c0Pbk;RFeqx$d^WI`5Ikm^f54|rP7pfqL%&+f zOIsVaF8oj}Y{m1_5yuMfy+oBv+|85Cv#y9wkZjNFcTfQk8k>ZF*q&qOUbGnpn1eUd zDe>bG@)}M%j-v6yvZyuks3V%B)^CrG1&Q_t;JVV`4msClqy_ZSI55^>-bT}=<5nVR z7)+0FV83{gj)R>SM==;kNe!iC9(`}3&?zw5Y2!KmQ)RgE=i%VFBJ@R*x#w{UJd-|!q!HB@D&5%xp4lb7<$(M zPUApdt#f}xwRE6?jFc>2UoVaS9Ju2|KL?TbtJsX?G;~kl^x!lt@l<4+sh@MfXXk!%B4);xZ60=OiB@R%EJy){pmI~WMG%Lp?IXd9+Qty^5h;~2H$T0vP32r;s#6f8Aq{5$r3V!Wjh^hAhTaoerDTVFEU?-WDZj#1$ah=i z!bV#Wa!N1hC$%5-v_CD=5R0pM)kW zW`J!(CL$qS2_E4`PDjdP5}$HbG71vWg(;Uj4QFA!J#5@%B6`vuv;IIOqSKzRC}J&Z z(Rn>ZBJZ^?smb|*^)>Y1bmXfqPu!2pKyRtuH;3I6WboZz>5rV)<|%P87eB~SHP3s& zmmO7Kq;OErPqA%>2qdM-Q=(SMeVm{h-v-B8THq(U>F!!7(y7nd{L{Z z>^a!GbsNsfI_;+ZoAReD^*ajr4ddS#nEyJdd>=>O^Al$B=IiC8w_R_sclCYTKO+tA z%W4cbdz6*tega(zlSi z6^{O&QvNgZon-}pC`TKmzj5%221NBmPX!hI?g>36U0$U5 zFADs2Jl=;EVJcnR%6_+ozMi_b*Z+&`t->Q7Keh3L`yZwJql0&@7$AE#sym_tO Ke8Shit^N;7x(KKM literal 0 HcmV?d00001 diff --git a/lightroom_companion/DESCRIPTION.txt b/lightroom_companion/DESCRIPTION.txt new file mode 100644 index 0000000..664fdf8 --- /dev/null +++ b/lightroom_companion/DESCRIPTION.txt @@ -0,0 +1,6 @@ +Companion plugin for the PiwigoPublish Lightroom plugin. Exposes server diagnostics, provides automatic video upload configuration, extended video metadata storage, and includes an administration page. + +It is a management interface that links Piwigo and Lightroom in order to use videos. It acts as a gateway between the systems. +It works in tandem. It's useless if you don't publish videos through Lightroom. + +Compatible with default themes as well as Bootstrap Darkroom. \ No newline at end of file diff --git a/lightroom_companion/admin.php b/lightroom_companion/admin.php index 5a411b6..918cf59 100644 --- a/lightroom_companion/admin.php +++ b/lightroom_companion/admin.php @@ -124,11 +124,13 @@ } // Piwigo config -$upload_all = isset($conf['upload_form_all_types']) ? (bool)$conf['upload_form_all_types'] : false; -$file_ext = isset($conf['file_ext']) ? $conf['file_ext'] : array(); -$video_exts_all = array('mp4', 'm4v', 'ogg', 'ogv', 'webm', 'webmv', 'mpg', 'mpeg', 'mov', 'avi'); -$found_video_exts = array_values(array_intersect($file_ext, $video_exts_all)); -$video_ready = $upload_all && !empty($found_video_exts); +// LRC_VIDEO_READY is derived from the config file on disk (not $conf in memory, +// which may be stale due to opcache after enable/disable actions). +$companion_block = companion_has_video_block(); +$video_ready = $companion_block; +// Read upload_all and file_ext from disk to avoid opcache stale values after enable/disable. +$upload_all = $companion_block; +$found_video_exts = $companion_block ? array('mp4', 'm4v', 'ogg', 'ogv', 'webm') : array(); $config_writable = companion_is_local_config_writable(); // VideoJS detection @@ -207,6 +209,7 @@ function companion_is_videojs($str) $template->assign(array( 'LRC_ADMIN_URL' => get_admin_plugin_menu_link(dirname(__FILE__).'/admin.php'), + 'LRC_CSS_URL' => get_root_url() . 'plugins/' . basename(dirname(__FILE__)) . '/assets/admin.css', 'PWG_TOKEN' => get_pwg_token(), 'LRC_TAB' => $page['tab'], 'LRC_PLUGIN_VERSION' => $lrc_plugin_version, @@ -264,7 +267,7 @@ function companion_is_videojs($str) 'LRC_CFG' => companion_get_all_config(), 'LRC_HAS_GD' => function_exists('imagecreatetruecolor'), 'LRC_HAS_VIDEO_ICON' => file_exists(dirname(__FILE__) . '/assets/video-icon.png'), - 'LRC_COMPANION_BLOCK' => companion_has_video_block(), + 'LRC_COMPANION_BLOCK' => $companion_block, )); // Render template diff --git a/lightroom_companion/admin.tpl b/lightroom_companion/admin.tpl index 4ca8bb6..e2bb27f 100644 --- a/lightroom_companion/admin.tpl +++ b/lightroom_companion/admin.tpl @@ -9,79 +9,49 @@ })(); - +{* ---- Animation pellicule (activation vidéo) ---- *} +
-

Lightroom Companion v{$LRC_PLUGIN_VERSION}

+

Lightroom Companion v{$LRC_PLUGIN_VERSION}

{* Tabsheet natif Piwigo *} {include file='tabsheet.tpl'} {* ---- Action result ---- *} {if $LRC_ACTION_STATUS eq 'ok' or $LRC_ACTION_STATUS eq 'already_configured'} -

{$LRC_ACTION_MESSAGE}

+

{$LRC_ACTION_MESSAGE}

{elseif $LRC_ACTION_STATUS} -

{$LRC_ACTION_MESSAGE}

+

{$LRC_ACTION_MESSAGE}

{/if} {* ================================================================= *} @@ -156,13 +126,13 @@
- +

{'lrc_disable_video_note'|translate}

{/if}
{elseif not $LRC_VIDEO_READY} -

{'lrc_config_not_writable'|translate}

+

{'lrc_config_not_writable'|translate}

$conf['upload_form_all_types'] = true; $conf['file_ext'] = array_merge($conf['picture_ext'], array('mp4', 'm4v', 'ogg', 'ogv', 'webm'));
{/if} @@ -288,7 +258,7 @@ $conf['file_ext'] = array_merge($conf['picture_ext'], array('mp4', 'm4v', 'ogg',
@@ -332,8 +302,8 @@ $conf['file_ext'] = array_merge($conf['picture_ext'], array('mp4', 'm4v', 'ogg', @@ -377,7 +347,7 @@ $conf['file_ext'] = array_merge($conf['picture_ext'], array('mp4', 'm4v', 'ogg', {'lrc_video_icon_option'|translate} {if not $LRC_HAS_VIDEO_ICON} - + ({'lrc_missing_asset'|translate}: assets/video-icon.png) {/if} @@ -407,23 +377,23 @@ $conf['file_ext'] = array_merge($conf['picture_ext'], array('mp4', 'm4v', 'ogg', {if $LRC_CFG.overlay_play}checked{/if}> {'lrc_play_button_option'|translate} - {'lrc_play_native_note'|translate} + {'lrc_play_native_note'|translate}
Version{$LRC_PIWIGO_VER}
Guest theme + {$LRC_PUBLIC_THEME} + {if $LRC_PARENT_THEME neq $LRC_PUBLIC_THEME} + ↳ parent: {$LRC_PARENT_THEME} + {/if} +
Config file writable @@ -290,4 +306,134 @@ $conf['file_ext'] = array_merge($conf['picture_ext'], array('mp4', 'm4v', 'ogg', {/if}{* end tab server *} + {* ================================================================= *} + {* TAB SETTINGS *} + {* ================================================================= *} + {if $LRC_TAB eq 'settings'} + + {if not $LRC_HAS_GD} +
+
!
+
+ GD library not available + Thumbnail processing requires the PHP GD extension. Posters will be stored as-is. +
+
+ {/if} + +
+ + + + {* --- Thumbnail size --- *} +
Video Thumbnail
+ + + + + + + + + +
Max size (px) + px + (longest side) +
No upscale + +
+ + {* --- Film strip --- *} +
Film Strip Effect
+ + + + + +
35mm film border + +
+

The thumbnail becomes square with black letterbox and 35mm-style sprocket holes on the sides.

+ + {* --- Overlays --- *} +
Overlays
+ + + + + + + + + + + + + + + + + + + + + +
Video icon (corner) + + {if not $LRC_HAS_VIDEO_ICON} + + (missing: assets/video-icon.png) + + {/if} +
Icon position + +    + +
Play button (center) + + drawn natively, no PNG needed +
Play button size + % + of the shortest side (5–50%) +
Play button opacity + % + transparency of the overlay (10–100%) +
+

Place your custom PNG file (with transparency) in the lightroom_companion/assets/ folder for the video icon overlay.

+ +
+ +
+
+ + {/if}{* end tab settings *} + diff --git a/lightroom_companion/assets/README.txt b/lightroom_companion/assets/README.txt new file mode 100644 index 0000000..f132a62 --- /dev/null +++ b/lightroom_companion/assets/README.txt @@ -0,0 +1,13 @@ +Lightroom Companion — Overlay Assets +===================================== + +Place your custom PNG files here (with transparency): + + video-icon.png Shown in a corner of the thumbnail (configurable position). + Recommended size: 128x128 px or larger (will be scaled to ~20% of thumbnail). + + play-button.png Shown centered on the thumbnail. + Recommended size: 256x256 px or larger (will be scaled to ~30% of thumbnail). + +Both files are optional. If missing, the corresponding overlay is disabled in Settings. +Configure overlays in: Piwigo Admin > Plugins > Lightroom Companion > Settings tab. diff --git a/lightroom_companion/main.inc.php b/lightroom_companion/main.inc.php new file mode 100644 index 0000000..68ea316 --- /dev/null +++ b/lightroom_companion/main.inc.php @@ -0,0 +1,1270 @@ + 'Lightroom Companion', + 'URL' => get_admin_plugin_menu_link(__DIR__ . '/admin.php'), + )); + return $menu; +} + +function companion_add_methods($arr) +{ + $service = &$arr[0]; + + $service->addMethod( + 'pwg.companion.getConfig', + 'companion_get_config', + array(), + 'Returns server configuration: PHP, upload limits, graphics libs, FFmpeg, video readiness.', + null, + array('admin_only' => true) + ); + + $service->addMethod( + 'pwg.companion.enableVideoSupport', + 'companion_enable_video_support', + array(), + 'Enables video upload support by writing upload_form_all_types and file_ext to local config.', + null, + array('admin_only' => true) + ); + + $service->addMethod( + 'pwg.companion.disableVideoSupport', + 'companion_disable_video_support', + array(), + 'Removes the Companion video block from local/config/config.inc.php.', + null, + array('admin_only' => true) + ); + + $service->addMethod( + 'pwg.companion.setRepresentative', + 'companion_set_representative', + array( + 'image_id' => array( + 'default' => null, + 'type' => WS_TYPE_INT, + 'info' => 'Piwigo image/video ID', + ), + ), + 'Upload a poster/thumbnail image as the representative for a video.', + null, + array('admin_only' => true) + ); + + $service->addMethod( + 'pwg.companion.setVideoInfo', + 'companion_set_video_info', + array( + 'image_id' => array( + 'default' => null, + 'type' => WS_TYPE_INT, + 'info' => 'Piwigo image/video ID', + ), + 'width' => array( + 'default' => null, + 'type' => WS_TYPE_INT, + 'info' => 'Video width in pixels', + ), + 'height' => array( + 'default' => null, + 'type' => WS_TYPE_INT, + 'info' => 'Video height in pixels', + ), + 'filesize' => array( + 'default' => null, + 'type' => WS_TYPE_INT, + 'info' => 'Video file size in bytes (optional)', + ), + ), + 'Sets video dimensions and optional filesize in the Piwigo images table.', + null, + array('admin_only' => true) + ); + + $service->addMethod( + 'pwg.companion.setVideoMeta', + 'companion_set_video_meta', + array( + 'image_id' => array('default' => null, 'type' => WS_TYPE_INT), + 'orig_width' => array('default' => null, 'type' => WS_TYPE_INT), + 'orig_height' => array('default' => null, 'type' => WS_TYPE_INT), + 'orig_fps' => array('default' => null), + 'orig_bitrate' => array('default' => null, 'type' => WS_TYPE_INT), + 'orig_codec' => array('default' => null), + 'orig_format' => array('default' => null), + 'orig_filesize'=> array('default' => null, 'type' => WS_TYPE_INT), + 'conv_width' => array('default' => null, 'type' => WS_TYPE_INT), + 'conv_height' => array('default' => null, 'type' => WS_TYPE_INT), + 'conv_fps' => array('default' => null), + 'conv_bitrate' => array('default' => null, 'type' => WS_TYPE_INT), + 'conv_codec' => array('default' => null), + 'conv_format' => array('default' => null), + 'conv_filesize'=> array('default' => null, 'type' => WS_TYPE_INT), + ), + 'Store extended video metadata (source + VTK variant) for a Piwigo image.', + null, + array('admin_only' => true) + ); +} + +// ========================================================================= +// pwg.companion.getConfig +// ========================================================================= +function companion_get_config($params, &$service) +{ + $result = array(); + + // ----- PHP ----- + $disabled_functions = array_map('trim', explode(',', ini_get('disable_functions'))); + $exec_available = function_exists('exec') && !in_array('exec', $disabled_functions); + + $result['php'] = array( + 'version' => PHP_VERSION, + 'memory_limit' => ini_get('memory_limit'), + 'upload_max_filesize' => ini_get('upload_max_filesize'), + 'post_max_size' => ini_get('post_max_size'), + 'max_execution_time' => ini_get('max_execution_time'), + 'max_input_time' => ini_get('max_input_time'), + 'max_file_uploads' => ini_get('max_file_uploads'), + 'exec_available' => $exec_available, + 'disabled_functions' => $exec_available ? '' : ini_get('disable_functions'), + ); + + // ----- Graphics library ----- + $gfx = array('gd' => false, 'imagick' => false); + + if (function_exists('gd_info')) + { + $gd = gd_info(); + $gfx['gd'] = array( + 'version' => isset($gd['GD Version']) ? $gd['GD Version'] : 'unknown', + 'jpeg' => !empty($gd['JPEG Support']), + 'png' => !empty($gd['PNG Support']), + 'webp' => !empty($gd['WebP Support']), + ); + } + + if (extension_loaded('imagick')) + { + try { + $im = new Imagick(); + $ver = Imagick::getVersion(); + $gfx['imagick'] = array( + 'version' => isset($ver['versionString']) ? $ver['versionString'] : 'unknown', + ); + } catch (Exception $e) { + $gfx['imagick'] = array('version' => 'error: ' . $e->getMessage()); + } + } + + $result['graphics'] = $gfx; + + // ----- CLI tools (FFmpeg, ExifTool, MediaInfo) ----- + if ($exec_available) + { + $result['ffmpeg'] = companion_detect_tool('ffmpeg', '-version'); + $result['ffprobe'] = companion_detect_tool('ffprobe', '-version'); + $result['exiftool'] = companion_detect_tool('exiftool', '-ver'); + $result['mediainfo'] = companion_detect_tool('mediainfo', '--Version'); + } + else + { + $notice = 'exec() is disabled by PHP configuration'; + $result['ffmpeg'] = array('installed' => false, 'notice' => $notice); + $result['ffprobe'] = array('installed' => false, 'notice' => $notice); + $result['exiftool'] = array('installed' => false, 'notice' => $notice); + $result['mediainfo'] = array('installed' => false, 'notice' => $notice); + } + + // ----- Piwigo config (video-relevant) ----- + global $conf; + + $upload_all = isset($conf['upload_form_all_types']) ? (bool)$conf['upload_form_all_types'] : false; + $file_ext = isset($conf['file_ext']) ? $conf['file_ext'] : array(); + $pic_ext = isset($conf['picture_ext']) ? $conf['picture_ext'] : array(); + + // Check for video extensions + $video_exts = array('mp4', 'm4v', 'ogg', 'ogv', 'webm', 'webmv', 'mpg', 'mpeg', 'mov', 'avi'); + $found_video_exts = array_values(array_intersect($file_ext, $video_exts)); + + $result['piwigo'] = array( + 'version' => PHPWG_VERSION, + 'upload_form_all_types' => $upload_all, + 'file_ext' => $file_ext, + 'picture_ext' => $pic_ext, + 'video_ext_configured' => $found_video_exts, + 'video_ready' => $upload_all && !empty($found_video_exts), + 'local_config_writable' => companion_is_local_config_writable(), + ); + + // ----- OS ----- + $result['server'] = array( + 'os' => PHP_OS, + 'software' => isset($_SERVER['SERVER_SOFTWARE']) ? $_SERVER['SERVER_SOFTWARE'] : 'unknown', + ); + + return $result; +} + +// ========================================================================= +// pwg.companion.enableVideoSupport +// ========================================================================= +function companion_enable_video_support($params, &$service) +{ + global $conf; + + $config_path = PHPWG_ROOT_PATH . 'local/config/config.inc.php'; + + // Check if already configured + $upload_all = isset($conf['upload_form_all_types']) ? (bool)$conf['upload_form_all_types'] : false; + $file_ext = isset($conf['file_ext']) ? $conf['file_ext'] : array(); + $video_exts = array('mp4', 'm4v', 'ogg', 'ogv', 'webm'); + $found = array_intersect($file_ext, $video_exts); + + if ($upload_all && count($found) >= count($video_exts)) + { + return array( + 'status' => 'already_configured', + 'message' => 'Video support is already enabled.', + ); + } + + // Check writable + if (!companion_is_local_config_writable()) + { + return array( + 'status' => 'error', + 'message' => 'Cannot write to ' . $config_path . '. Check file permissions.', + ); + } + + // Read current file content + $content = ''; + if (file_exists($config_path)) + { + $content = file_get_contents($config_path); + } + + // Build lines to append + $lines_to_add = array(); + $lines_to_add[] = ''; + $lines_to_add[] = '// --- PiwigoPublish Companion: video upload support ---'; + + if (!$upload_all) + { + $lines_to_add[] = "\$conf['upload_form_all_types'] = true;"; + } + + // Always write file_ext with merge to ensure video extensions are present + $lines_to_add[] = "\$conf['file_ext'] = array_merge("; + $lines_to_add[] = " \$conf['picture_ext'],"; + $lines_to_add[] = " array('mp4', 'm4v', 'ogg', 'ogv', 'webm')"; + $lines_to_add[] = ");"; + + // Check if file has PHP opening tag + $php_open_tag = '<' . '?php'; + $php_close_tag = '?' . '>'; + if (empty($content) || strpos($content, $php_open_tag) === false) + { + $content = $php_open_tag . "\n" . implode("\n", $lines_to_add) . "\n"; + } + else + { + /* Remove trailing close-tag if present (we'll leave the file open) */ + $content = rtrim($content); + if (substr($content, -2) === $php_close_tag) + { + $content = rtrim(substr($content, 0, -2)); + } + $content .= "\n" . implode("\n", $lines_to_add) . "\n"; + } + + // Write + $written = @file_put_contents($config_path, $content); + if ($written === false) + { + return array( + 'status' => 'error', + 'message' => 'Failed to write to ' . $config_path, + ); + } + + return array( + 'status' => 'ok', + 'message' => 'Video support has been enabled. Video extensions (mp4, m4v, ogg, ogv, webm) are now allowed.', + ); +} + +// ========================================================================= +// pwg.companion.disableVideoSupport +// ========================================================================= +function companion_disable_video_support($params, &$service) +{ + $config_path = PHPWG_ROOT_PATH . 'local/config/config.inc.php'; + $marker = '// --- PiwigoPublish Companion: video upload support ---'; + + if (!file_exists($config_path)) + { + return array('status' => 'error', 'message' => 'Config file not found.'); + } + + if (!is_writable($config_path)) + { + return array('status' => 'error', 'message' => 'Config file is not writable.'); + } + + $content = file_get_contents($config_path); + + $pos = strpos($content, $marker); + if ($pos === false) + { + return array('status' => 'already_configured', 'message' => 'Companion block not found — nothing to remove.'); + } + + // Remove from the blank line just before the marker to the end of the block. + // The block ends at the last semicolon line after the marker. + // Strategy: find the newline before $pos (the blank separator line), remove everything from there to end of block. + // We remove: optional preceding \n, then marker line + all following lines until the next empty line or EOF. + $block_start = $pos; + // Walk back to include the preceding blank line (\n\n before marker) + if ($block_start >= 2 && substr($content, $block_start - 1, 1) === "\n") + $block_start--; + + // Find end of block: scan forward until blank line or end of string + $block_end = $pos + strlen($marker); + $len = strlen($content); + while ($block_end < $len) + { + $nl = strpos($content, "\n", $block_end); + if ($nl === false) { $block_end = $len; break; } + $line = substr($content, $block_end, $nl - $block_end + 1); + $block_end = $nl + 1; + if (trim($line) === '') break; // blank line = end of block + } + + $content = substr($content, 0, $block_start) . substr($content, $block_end); + $content = rtrim($content) . "\n"; + + $written = @file_put_contents($config_path, $content); + if ($written === false) + { + return array('status' => 'error', 'message' => 'Failed to write to ' . $config_path); + } + + return array('status' => 'ok', 'message' => 'Video support has been disabled. The Companion block has been removed from local/config/config.inc.php.'); +} + +/** + * Check if the Companion video block is present in local config. + */ +function companion_has_video_block() +{ + $config_path = PHPWG_ROOT_PATH . 'local/config/config.inc.php'; + if (!file_exists($config_path)) return false; + return strpos(file_get_contents($config_path), '// --- PiwigoPublish Companion: video upload support ---') !== false; +} + +// ========================================================================= +// pwg.companion.setRepresentative +// ========================================================================= +function companion_set_representative($params, &$service) +{ + global $conf; + + $image_id = (int)$params['image_id']; + if ($image_id <= 0) + { + return new PwgError(WS_ERR_INVALID_PARAM, 'image_id must be a positive integer'); + } + + // Verify image exists + $query = 'SELECT id, path FROM ' . IMAGES_TABLE . ' WHERE id = ' . $image_id . ';'; + $result = pwg_query($query); + $row = pwg_db_fetch_assoc($result); + if (!$row) + { + return new PwgError(404, 'Image ' . $image_id . ' not found'); + } + + // Expect an uploaded file named 'file' + if (empty($_FILES['file']) || $_FILES['file']['error'] !== UPLOAD_ERR_OK) + { + $err = isset($_FILES['file']['error']) ? $_FILES['file']['error'] : 'no file'; + return new PwgError(WS_ERR_INVALID_PARAM, 'No valid file uploaded (error: ' . $err . ')'); + } + + // Determine storage directory from existing image path + // path is relative to PHPWG_ROOT_PATH, e.g. "upload/2024/01/01/2024010...jpg" + $image_dir = PHPWG_ROOT_PATH . dirname($row['path']); + if (!is_dir($image_dir)) + { + return new PwgError(500, 'Image directory not found: ' . $image_dir); + } + + // Build representative filename: same basename, extension = uploaded file extension + $uploaded_ext = strtolower(pathinfo($_FILES['file']['name'], PATHINFO_EXTENSION)); + if (!in_array($uploaded_ext, array('jpg', 'jpeg', 'png', 'webp'))) + { + return new PwgError(WS_ERR_INVALID_PARAM, 'Poster must be jpg, jpeg, png or webp'); + } + + // Piwigo representative: stored in pwg_representative/ subdirectory + $image_basename = pathinfo($row['path'], PATHINFO_FILENAME); + $representative_filename = $image_basename . '.' . $uploaded_ext; + $representative_dir = $image_dir . '/pwg_representative'; + if (!is_dir($representative_dir)) + { + @mkdir($representative_dir, 0755, true); + } + $representative_path = $representative_dir . '/' . $representative_filename; + + if (!move_uploaded_file($_FILES['file']['tmp_name'], $representative_path)) + { + return new PwgError(500, 'Failed to move uploaded poster to ' . $representative_path); + } + + // Process thumbnail: resize + film strip + overlays (if GD available) + companion_process_representative($representative_path); + + // Invalidate Piwigo derivative cache for this image + $query = 'UPDATE ' . IMAGES_TABLE + . " SET representative_ext = '" . pwg_db_real_escape_string($uploaded_ext) . "'" + . ' WHERE id = ' . $image_id . ';'; + pwg_query($query); + + // Delete cached derivatives so Piwigo regenerates thumbnails + $image_path = PHPWG_ROOT_PATH . $row['path']; + if (function_exists('delete_element_derivatives')) + { + $element_info = array('id' => $image_id, 'path' => $row['path']); + delete_element_derivatives($element_info); + } + + return array( + 'status' => 'ok', + 'image_id' => $image_id, + 'representative_ext' => $uploaded_ext, + 'representative_path' => $representative_filename, + ); +} + +// ========================================================================= +// pwg.companion.setVideoInfo +// ========================================================================= +function companion_set_video_info($params, &$service) +{ + $image_id = (int)$params['image_id']; + if ($image_id <= 0) + { + return new PwgError(WS_ERR_INVALID_PARAM, 'image_id must be a positive integer'); + } + + // Verify image exists + $query = 'SELECT id FROM ' . IMAGES_TABLE . ' WHERE id = ' . $image_id . ';'; + $result = pwg_query($query); + $row = pwg_db_fetch_assoc($result); + if (!$row) + { + return new PwgError(404, 'Image ' . $image_id . ' not found'); + } + + // Build SET clause from provided parameters + $updates = array(); + + if (isset($params['width']) && $params['width'] !== null) + { + $width = (int)$params['width']; + if ($width > 0) $updates[] = 'width = ' . $width; + } + + if (isset($params['height']) && $params['height'] !== null) + { + $height = (int)$params['height']; + if ($height > 0) $updates[] = 'height = ' . $height; + } + + if (isset($params['filesize']) && $params['filesize'] !== null) + { + // Piwigo stores filesize in KB in the images table + $filesize_bytes = (int)$params['filesize']; + if ($filesize_bytes > 0) + { + $filesize_kb = (int)ceil($filesize_bytes / 1024); + $updates[] = 'filesize = ' . $filesize_kb; + } + } + + if (empty($updates)) + { + return new PwgError(WS_ERR_INVALID_PARAM, 'At least one of width, height, or filesize must be provided'); + } + + $query = 'UPDATE ' . IMAGES_TABLE + . ' SET ' . implode(', ', $updates) + . ' WHERE id = ' . $image_id . ';'; + pwg_query($query); + + return array( + 'status' => 'ok', + 'image_id' => $image_id, + 'updated' => $updates, + ); +} + +// ========================================================================= +// Database install (CREATE TABLE IF NOT EXISTS on init) +// ========================================================================= +function companion_install() +{ + global $prefixeTable, $conf; + + // Use a version flag to avoid running CREATE TABLE on every page load. + // Only run migrations when the version changes. + $current_version = '1.4.0'; + $installed_version = isset($conf['companion_version']) ? $conf['companion_version'] : ''; + + if ($installed_version === $current_version) return; + + // --- Video metadata table --- + $table = $prefixeTable . 'companion_video_meta'; + $query = 'CREATE TABLE IF NOT EXISTS ' . $table . ' ( + image_id INT UNSIGNED NOT NULL, + orig_width SMALLINT UNSIGNED DEFAULT NULL, + orig_height SMALLINT UNSIGNED DEFAULT NULL, + orig_fps DECIMAL(6,3) DEFAULT NULL, + orig_bitrate INT UNSIGNED DEFAULT NULL, + orig_codec VARCHAR(20) DEFAULT NULL, + orig_format VARCHAR(10) DEFAULT NULL, + orig_filesize BIGINT UNSIGNED DEFAULT NULL, + conv_width SMALLINT UNSIGNED DEFAULT NULL, + conv_height SMALLINT UNSIGNED DEFAULT NULL, + conv_fps DECIMAL(6,3) DEFAULT NULL, + conv_bitrate INT UNSIGNED DEFAULT NULL, + conv_codec VARCHAR(20) DEFAULT NULL, + conv_format VARCHAR(10) DEFAULT NULL, + conv_filesize BIGINT UNSIGNED DEFAULT NULL, + updated_at DATETIME DEFAULT NULL, + PRIMARY KEY (image_id) + ) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;'; + pwg_query($query); + + // --- Plugin config (default values) --- + if (!isset($conf['companion_config'])) + { + $default_config = array( + 'thumb_max_size' => 350, + 'thumb_no_upscale' => true, + 'film_strip' => false, + 'overlay_video_icon'=> false, + 'overlay_video_pos' => 'bottom-right', + 'overlay_play' => false, + 'overlay_play_size' => 20, // % du côté le plus court + 'overlay_play_opacity' => 100, // 0-100 + ); + conf_update_param('companion_config', json_encode($default_config)); + $conf['companion_config'] = json_encode($default_config); + } + + // Mark installed version + conf_update_param('companion_version', $current_version); + $conf['companion_version'] = $current_version; +} + +/** + * Read a single config value from companion_config JSON + */ +function companion_get_config_value($key, $default = null) +{ + global $conf; + if (!isset($conf['companion_config'])) return $default; + $cfg = json_decode($conf['companion_config'], true); + return (is_array($cfg) && array_key_exists($key, $cfg)) ? $cfg[$key] : $default; +} + +/** + * Read all companion config as array + */ +function companion_get_all_config() +{ + global $conf; + $defaults = array( + 'thumb_max_size' => 350, + 'thumb_no_upscale' => true, + 'film_strip' => false, + 'overlay_video_icon' => false, + 'overlay_video_pos' => 'bottom-right', + 'overlay_play' => false, + 'overlay_play_size' => 20, + 'overlay_play_opacity'=> 100, + ); + if (!isset($conf['companion_config'])) return $defaults; + $cfg = json_decode($conf['companion_config'], true); + if (!is_array($cfg)) return $defaults; + return array_merge($defaults, $cfg); +} + +// ========================================================================= +// pwg.companion.setVideoMeta +// ========================================================================= +function companion_set_video_meta($params, &$service) +{ + global $prefixeTable; + + $image_id = (int)$params['image_id']; + if ($image_id <= 0) + { + return new PwgError(WS_ERR_INVALID_PARAM, 'image_id must be a positive integer'); + } + + // Verify image exists + $query = 'SELECT id FROM ' . IMAGES_TABLE . ' WHERE id = ' . $image_id . ';'; + $result = pwg_query($query); + if (!pwg_db_fetch_assoc($result)) + { + return new PwgError(404, 'Image ' . $image_id . ' not found'); + } + + $fields = array( + 'orig_width', 'orig_height', 'orig_fps', 'orig_bitrate', + 'orig_codec', 'orig_format', 'orig_filesize', + 'conv_width', 'conv_height', 'conv_fps', 'conv_bitrate', + 'conv_codec', 'conv_format', 'conv_filesize', + ); + $str_fields = array('orig_codec', 'orig_format', 'conv_codec', 'conv_format'); + + $insert_cols = array('image_id'); + $insert_vals = array($image_id); + $update_parts = array(); + + foreach ($fields as $field) + { + if (isset($params[$field]) && $params[$field] !== null && $params[$field] !== '') + { + $insert_cols[] = $field; + if (in_array($field, $str_fields)) + { + $val = "'" . pwg_db_real_escape_string($params[$field]) . "'"; + } + else + { + $val = (float)$params[$field]; + } + $insert_vals[] = $val; + $update_parts[] = $field . ' = VALUES(' . $field . ')'; + } + } + + $update_parts[] = "updated_at = NOW()"; + + $table = $prefixeTable . 'companion_video_meta'; + $query = 'INSERT INTO ' . $table + . ' (' . implode(', ', $insert_cols) . ')' + . ' VALUES (' . implode(', ', $insert_vals) . ')' + . ' ON DUPLICATE KEY UPDATE ' . implode(', ', $update_parts) . ';'; + pwg_query($query); + + return array('status' => 'ok', 'image_id' => $image_id); +} + +// ========================================================================= +// Hook affichage picture.php — métadonnées vidéo étendues +// ========================================================================= +function companion_picture_video_meta() +{ + global $template, $page, $prefixeTable; + + if (!isset($page['image_id'])) return; + + $image_id = (int)$page['image_id']; + $table = $prefixeTable . 'companion_video_meta'; + $query = 'SELECT * FROM ' . $table . ' WHERE image_id = ' . $image_id . ';'; + $result = pwg_query($query); + $row = pwg_db_fetch_assoc($result); + if (!$row) return; + + $orig = companion_format_video_line($row, 'orig'); + $conv = companion_format_video_line($row, 'conv'); + + $template->assign(array( + 'VTK_VIDEO_ORIG' => $orig, + 'VTK_VIDEO_CONV' => $conv, + )); + + // Injection strategy based on parent theme + $parent = companion_get_parent_theme(); + switch ($parent) + { + case 'bootstrap_darkroom': + $layout = companion_get_bdr_layout(); + if ($layout === 'sidebar') + { + $template->set_prefilter('picture', 'companion_inject_sidebar'); + } + else + { + $template->set_prefilter('picture', 'companion_inject_cards'); + } + break; + case 'default': + case 'elegant': + case 'smartpocket': + $template->set_prefilter('picture', 'companion_inject_default'); + break; + default: + // Try BDR cards first, fall back to default if anchor not found + $template->set_prefilter('picture', 'companion_inject_auto'); + break; + } +} + +function companion_get_public_theme() +{ + // user_id = 2 = guest dans Piwigo (convention interne fixe) + $query = "SELECT theme FROM " . USER_INFOS_TABLE . " WHERE user_id = 2 LIMIT 1;"; + $result = pwg_query($query); + if ($result) + { + $row = pwg_db_fetch_assoc($result); + if ($row && !empty($row['theme'])) + return $row['theme']; + } + return 'default'; +} + +function companion_get_parent_theme() +{ + $theme = companion_get_public_theme(); + $themeconf_path = PHPWG_ROOT_PATH . 'themes/' . $theme . '/themeconf.inc.php'; + if (file_exists($themeconf_path)) + { + $themeconf = array(); + include($themeconf_path); + if (isset($themeconf['parent'])) + { + return $themeconf['parent']; + } + } + return $theme; +} + +function companion_get_bdr_layout() +{ + global $conf; + + if (!isset($conf['bootstrap_darkroom'])) + { + return 'cards'; + } + + $bdr = json_decode($conf['bootstrap_darkroom'], true); + if (is_array($bdr) && isset($bdr['picture_info']) + && in_array($bdr['picture_info'], array('sidebar', 'cards', 'tabs'))) + { + return $bdr['picture_info']; + } + + return 'cards'; +} + +function companion_inject_cards($content, &$smarty) +{ + $search = '{if isset($VTK_VIDEO_ORIG)}'; + // Already injected? Don't double-inject. + if (strpos($content, $search) !== false) return $content; + + $anchor = '
', $pos); + if ($end === false) return $content; + + $inject = ' +{if isset($VTK_VIDEO_ORIG)} +
+
+
{\'Video (original)\'|translate}
+
{$VTK_VIDEO_ORIG}
+
+
+
{\'Video (converted)\'|translate}
+
{$VTK_VIDEO_CONV}
+
+
+{/if}'; + + return substr($content, 0, $end + 1) . $inject . substr($content, $end + 1); +} + +function companion_inject_sidebar($content, &$smarty) +{ + $search = '{if isset($VTK_VIDEO_ORIG)}'; + if (strpos($content, $search) !== false) return $content; + + $anchor = '
', $pos); + if ($end === false) return $content; + + $inject = ' +{if isset($VTK_VIDEO_ORIG)} +
+
{\'Video (original)\'|translate}
+
{$VTK_VIDEO_ORIG}
+
{\'Video (converted)\'|translate}
+
{$VTK_VIDEO_CONV}
+
+{/if}'; + + return substr($content, 0, $end + 1) . $inject . substr($content, $end + 1); +} + +function companion_inject_default($content, &$smarty) +{ + $search = '{if isset($VTK_VIDEO_ORIG)}'; + if (strpos($content, $search) !== false) return $content; + + // Piwigo default/elegant/smartpocket: inject inside
+ $anchor = '
'; + $pos = strpos($content, $anchor); + if ($pos === false) return $content; + + $inject_pos = $pos + strlen($anchor); + + $inject = ' +{if isset($VTK_VIDEO_ORIG)} +
+
{\'Video (original)\'|translate}
+
{$VTK_VIDEO_ORIG}
+
+
+
{\'Video (converted)\'|translate}
+
{$VTK_VIDEO_CONV}
+
+{/if}'; + + return substr($content, 0, $inject_pos) . $inject . substr($content, $inject_pos); +} + +function companion_inject_auto($content, &$smarty) +{ + // Try BDR cards anchor first + if (strpos($content, '
0 && $h > 0) $parts[] = $w . "\xc3\x97" . $h; + + $fps = (float)($row[$prefix . '_fps'] ?? 0); + if ($fps > 0) $parts[] = rtrim(rtrim(number_format($fps, 3, '.', ''), '0'), '.') . ' fps'; + + $kbps = (int)($row[$prefix . '_bitrate'] ?? 0); + if ($kbps > 0) + { + $parts[] = $kbps >= 1000 + ? '@' . number_format($kbps / 1000, 1) . ' Mbps' + : '@' . $kbps . ' kbps'; + } + + $codec = trim($row[$prefix . '_codec'] ?? ''); + if ($codec !== '') $parts[] = strtoupper($codec); + + $fmt = trim($row[$prefix . '_format'] ?? ''); + if ($fmt !== '') $parts[] = strtolower($fmt); + + $bytes = (int)($row[$prefix . '_filesize'] ?? 0); + if ($bytes > 0) + { + $mb = $bytes / (1024 * 1024); + $parts[] = '(' . ($mb >= 1 + ? number_format($mb, 0, ',', ' ') . ' Mo' + : number_format($bytes / 1024, 0, ',', ' ') . ' Ko') . ')'; + } + + return implode('  ', $parts); +} + +// ========================================================================= +// Thumbnail processing (GD) +// ========================================================================= + +/** + * Process a representative image: resize, film strip, overlays. + * Modifies the file in place. Requires GD. + */ +function companion_process_representative($path) +{ + if (!function_exists('imagecreatetruecolor')) return; + if (!file_exists($path)) return; + + $cfg = companion_get_all_config(); + + // Load source image + $ext = strtolower(pathinfo($path, PATHINFO_EXTENSION)); + $src = companion_gd_load($path, $ext); + if (!$src) return; + + $src_w = imagesx($src); + $src_h = imagesy($src); + + // --- 1. Resize --- + $max = (int)$cfg['thumb_max_size']; + if ($max <= 0) $max = 350; + + $longest = max($src_w, $src_h); + if ($longest > $max || !$cfg['thumb_no_upscale']) + { + if ($longest > $max) + { + $ratio = $max / $longest; + $new_w = (int)round($src_w * $ratio); + $new_h = (int)round($src_h * $ratio); + $resized = imagecreatetruecolor($new_w, $new_h); + imagecopyresampled($resized, $src, 0, 0, 0, 0, $new_w, $new_h, $src_w, $src_h); + imagedestroy($src); + $src = $resized; + $src_w = $new_w; + $src_h = $new_h; + } + } + + // --- 2. Film strip (creates a square image) --- + if ($cfg['film_strip']) + { + $src = companion_gd_film_strip($src, $src_w, $src_h); + $src_w = imagesx($src); + $src_h = imagesy($src); + } + + // --- 3. Overlay: video icon --- + if ($cfg['overlay_video_icon']) + { + $icon_path = dirname(__FILE__) . '/assets/video-icon.png'; + if (file_exists($icon_path)) + { + $icon = imagecreatefrompng($icon_path); + if ($icon) + { + $icon_size = (int)round(min($src_w, $src_h) * 0.20); + $icon_w = imagesx($icon); + $icon_h = imagesy($icon); + $scale = $icon_size / max($icon_w, $icon_h); + $scaled_w = (int)round($icon_w * $scale); + $scaled_h = (int)round($icon_h * $scale); + + $scaled_icon = imagecreatetruecolor($scaled_w, $scaled_h); + imagealphablending($scaled_icon, false); + imagesavealpha($scaled_icon, true); + $trans = imagecolorallocatealpha($scaled_icon, 0, 0, 0, 127); + imagefilledrectangle($scaled_icon, 0, 0, $scaled_w, $scaled_h, $trans); + imagecopyresampled($scaled_icon, $icon, 0, 0, 0, 0, $scaled_w, $scaled_h, $icon_w, $icon_h); + imagedestroy($icon); + + $margin = (int)round(min($src_w, $src_h) * 0.04); + $pos = $cfg['overlay_video_pos']; + if ($pos === 'bottom-left') + { + $dx = $margin; + } + else + { + $dx = $src_w - $scaled_w - $margin; + } + $dy = $src_h - $scaled_h - $margin; + + imagealphablending($src, true); + imagecopy($src, $scaled_icon, $dx, $dy, 0, 0, $scaled_w, $scaled_h); + imagedestroy($scaled_icon); + } + } + } + + // --- 4. Overlay: play button (center, drawn natively in GD) --- + if ($cfg['overlay_play']) + { + $size_pct = isset($cfg['overlay_play_size']) ? (int)$cfg['overlay_play_size'] : 20; + $opacity_pct = isset($cfg['overlay_play_opacity']) ? (int)$cfg['overlay_play_opacity'] : 70; + + $btn = companion_gd_play_button( + (int)round(min($src_w, $src_h) * ($size_pct / 100.0)), + $opacity_pct + ); + if ($btn) + { + $btn_w = imagesx($btn); + $btn_h = imagesy($btn); + $dx = (int)round(($src_w - $btn_w) / 2); + $dy = (int)round(($src_h - $btn_h) / 2); + imagealphablending($src, true); + imagecopy($src, $btn, $dx, $dy, 0, 0, $btn_w, $btn_h); + imagedestroy($btn); + } + } + + // --- 5. Save --- + imagejpeg($src, $path, 90); + imagedestroy($src); +} + +/** + * Load an image via GD from path + extension + */ +function companion_gd_load($path, $ext) +{ + switch ($ext) + { + case 'jpg': case 'jpeg': + return @imagecreatefromjpeg($path); + case 'png': + return @imagecreatefrompng($path); + case 'webp': + if (function_exists('imagecreatefromwebp')) + return @imagecreatefromwebp($path); + return false; + default: + return false; + } +} + +/** + * Apply 35mm film strip effect. + * Returns a new square GD resource with perforated borders. + */ +function companion_gd_film_strip($src, $src_w, $src_h) +{ + // Strip width = 12% of the longest side + $side = max($src_w, $src_h); + $strip_w = (int)round($side * 0.12); + + // Final canvas is square: image width + 2 strips, height = max(src_h, src_w + 2*strip) + $canvas_w = $src_w + 2 * $strip_w; + $canvas_h = max($src_h, $canvas_w); + // Make it square + $sq = max($canvas_w, $canvas_h); + + $canvas = imagecreatetruecolor($sq, $sq); + $black = imagecolorallocate($canvas, 0, 0, 0); + imagefilledrectangle($canvas, 0, 0, $sq - 1, $sq - 1, $black); + + // Film strip background (very dark gray) + $film_color = imagecolorallocate($canvas, 26, 26, 26); + // Left strip + imagefilledrectangle($canvas, 0, 0, $strip_w - 1, $sq - 1, $film_color); + // Right strip + imagefilledrectangle($canvas, $sq - $strip_w, 0, $sq - 1, $sq - 1, $film_color); + + // Draw sprocket holes + $hole_w = (int)round($strip_w * 0.45); + $hole_h = (int)round($hole_w * 0.7); + $spacing = (int)round($hole_h * 2.5); + $hole_color = imagecolorallocate($canvas, 0, 0, 0); + $edge_color = imagecolorallocate($canvas, 50, 50, 50); + + // Margin from strip edge + $hole_x_left = (int)round(($strip_w - $hole_w) / 2); + $hole_x_right = $sq - $strip_w + $hole_x_left; + + $y = (int)round($spacing * 0.4); + while ($y + $hole_h < $sq) + { + // Left hole + imagefilledrectangle($canvas, $hole_x_left, $y, $hole_x_left + $hole_w - 1, $y + $hole_h - 1, $hole_color); + imagerectangle($canvas, $hole_x_left, $y, $hole_x_left + $hole_w - 1, $y + $hole_h - 1, $edge_color); + // Right hole + imagefilledrectangle($canvas, $hole_x_right, $y, $hole_x_right + $hole_w - 1, $y + $hole_h - 1, $hole_color); + imagerectangle($canvas, $hole_x_right, $y, $hole_x_right + $hole_w - 1, $y + $hole_h - 1, $edge_color); + $y += $spacing; + } + + // Thin frame lines around image area + $frame = imagecolorallocate($canvas, 40, 40, 40); + imagerectangle($canvas, $strip_w - 1, 0, $sq - $strip_w, $sq - 1, $frame); + + // Center the source image + $dx = $strip_w; + $dy = (int)round(($sq - $src_h) / 2); + imagecopy($canvas, $src, $dx, $dy, 0, 0, $src_w, $src_h); + imagedestroy($src); + + return $canvas; +} + +/** + * Draw a YouTube-style play button natively in GD. + * Returns a truecolor GD image (transparent background) of size $size × $size, + * ready to be composited with imagecopy() on an alphablending-enabled canvas. + * + * $size : side length in pixels (the button is square) + * $opacity_pct : 0 (invisible) → 100 (fully opaque) + */ +function companion_gd_play_button($size, $opacity_pct = 70) +{ + $size = max(16, $size); + // GD alpha: 0 = fully opaque, 127 = fully transparent + $gd_alpha = (int)round(127 * (1.0 - max(0, min(100, $opacity_pct)) / 100.0)); + + $img = imagecreatetruecolor($size, $size); + imagealphablending($img, false); + imagesavealpha($img, true); + + // Fill with full transparency + $clear = imagecolorallocatealpha($img, 0, 0, 0, 127); + imagefilledrectangle($img, 0, 0, $size - 1, $size - 1, $clear); + + // --- Rounded rectangle background --- + // Proportions matching the YouTube icon: W:H = 4:3, radius ~18% of height + $bg_w = (int)round($size * 0.90); + $bg_h = (int)round($bg_w * 0.75); + $bg_x = (int)round(($size - $bg_w) / 2); + $bg_y = (int)round(($size - $bg_h) / 2); + $radius = (int)round($bg_h * 0.18); + $bg_col = imagecolorallocatealpha($img, 80, 80, 80, $gd_alpha); + + imagealphablending($img, true); + + // Fill rounded rect: center + 4 edges + 4 corner arcs + imagefilledrectangle($img, $bg_x + $radius, $bg_y, $bg_x + $bg_w - $radius, $bg_y + $bg_h, $bg_col); + imagefilledrectangle($img, $bg_x, $bg_y + $radius, $bg_x + $bg_w, $bg_y + $bg_h - $radius, $bg_col); + imagefilledarc($img, $bg_x + $radius, $bg_y + $radius, $radius * 2, $radius * 2, 180, 270, $bg_col, IMG_ARC_PIE); + imagefilledarc($img, $bg_x + $bg_w - $radius, $bg_y + $radius, $radius * 2, $radius * 2, 270, 360, $bg_col, IMG_ARC_PIE); + imagefilledarc($img, $bg_x + $radius, $bg_y + $bg_h - $radius, $radius * 2, $radius * 2, 90, 180, $bg_col, IMG_ARC_PIE); + imagefilledarc($img, $bg_x + $bg_w - $radius, $bg_y + $bg_h - $radius, $radius * 2, $radius * 2, 0, 90, $bg_col, IMG_ARC_PIE); + + // --- Triangle (play arrow) --- + // Centered in the rounded rect, slightly right-offset for optical balance + $tri_h = (int)round($bg_h * 0.48); + $tri_w = (int)round($tri_h * 0.87); // equilateral-ish + $tri_cx = (int)round($bg_x + $bg_w * 0.52); // slight optical right shift + $tri_cy = (int)round($bg_y + $bg_h / 2); + + $tri_col = imagecolorallocatealpha($img, 255, 255, 255, $gd_alpha); + imagefilledpolygon($img, array( + $tri_cx - (int)round($tri_w * 0.40), $tri_cy - (int)round($tri_h / 2), // top-left + $tri_cx - (int)round($tri_w * 0.40), $tri_cy + (int)round($tri_h / 2), // bottom-left + $tri_cx + (int)round($tri_w * 0.60), $tri_cy, // right (tip) + ), 3, $tri_col); + + imagealphablending($img, false); + return $img; +} + +// ========================================================================= +// Helpers +// ========================================================================= + +/** + * Check if local config file is writable (or parent dir is writable if file doesn't exist) + */ +function companion_is_local_config_writable() +{ + $config_path = PHPWG_ROOT_PATH . 'local/config/config.inc.php'; + if (file_exists($config_path)) + { + return is_writable($config_path); + } + // File doesn't exist — check if directory is writable + $dir = dirname($config_path); + return is_dir($dir) && is_writable($dir); +} + +/** + * Detect a CLI tool: find its path and get version output + */ +function companion_detect_tool($name, $version_flag) +{ + $result = array('installed' => false); + + $path = companion_find_executable($name); + if ($path === false) + { + return $result; + } + + $result['installed'] = true; + $result['path'] = $path; + + $output = array(); + @exec(escapeshellarg($path) . ' ' . $version_flag . ' 2>&1', $output); + if (!empty($output)) + { + $result['version'] = trim($output[0]); + } + + return $result; +} + +/** + * Try to find an executable in PATH or common locations + */ +function companion_find_executable($name) +{ + // Try which/where + $cmd = (PHP_OS_FAMILY === 'Windows') ? 'where' : 'which'; + $output = array(); + $return_var = -1; + @exec($cmd . ' ' . escapeshellarg($name) . ' 2>&1', $output, $return_var); + + if ($return_var === 0 && !empty($output)) + { + return trim($output[0]); + } + + // Fallback: common paths + $paths = array( + '/usr/bin/', + '/usr/local/bin/', + '/opt/bin/', + '/opt/local/bin/', + '/snap/bin/', + ); + + foreach ($paths as $path) + { + if (file_exists($path . $name)) + { + return $path . $name; + } + } + + return false; +} diff --git a/piwigoPublish.lrplugin/Init.lua b/piwigoPublish.lrplugin/Init.lua index d71dd15..87bb648 100644 --- a/piwigoPublish.lrplugin/Init.lua +++ b/piwigoPublish.lrplugin/Init.lua @@ -51,6 +51,8 @@ _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 -- Detect macOS vs Windows based on path separator in Lightroom's standard paths @@ -62,15 +64,14 @@ _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/PiwigoAPI.lua b/piwigoPublish.lrplugin/PiwigoAPI.lua index c4e51fc..115bd2b 100644 --- a/piwigoPublish.lrplugin/PiwigoAPI.lua +++ b/piwigoPublish.lrplugin/PiwigoAPI.lua @@ -3506,5 +3506,52 @@ function PiwigoAPI.setVideoInfo(propertyTable, imageId, width, height, filesize) return callStatus end +-- ************************************************* +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 + -- ************************************************* return PiwigoAPI diff --git a/piwigoPublish.lrplugin/PluginInfoDialogSections.lua b/piwigoPublish.lrplugin/PluginInfoDialogSections.lua index 55af29e..d685410 100644 --- a/piwigoPublish.lrplugin/PluginInfoDialogSections.lua +++ b/piwigoPublish.lrplugin/PluginInfoDialogSections.lua @@ -61,8 +61,8 @@ function PluginInfoDialogSections.startDialog(propertyTable) 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 -- Initialize update check preference @@ -72,17 +72,13 @@ function PluginInfoDialogSections.startDialog(propertyTable) -- Apply debug settings if prefs.debugEnabled then - if prefs.debugToFile then - log:enable("logfile") - else - log:enable("print") - end + log:enable("logfile") else log:disable() end propertyTable.debugEnabled = prefs.debugEnabled - propertyTable.debugToFile = prefs.debugToFile + propertyTable.clearLogOnReload = prefs.clearLogOnReload propertyTable.checkUpdatesOnStartup = prefs.checkUpdatesOnStartup end @@ -155,19 +151,21 @@ function PluginInfoDialogSections.sectionsForTopOfDialog(f, propertyTable) }, f:row { + f:spacer { fill_horizontal = 1 }, f:push_button { title = "Visit Plugin Page…", action = function() LrHttp.openUrlInBrowser(GITHUB_URL) end, }, + f:spacer { fill_horizontal = 1 }, }, f:row { f:static_text { title = "Made in England with cider and cheddar cheese in Somerset,\n" .. "the Land of the Summer People.", - font = "", + font = "", text_color = LrColor(0.5, 0.5, 0.5), alignment = 'center', fill_horizontal = 1, @@ -288,8 +286,8 @@ function PluginInfoDialogSections.sectionsForTopOfDialog(f, propertyTable) f:row { 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.", + title = "If you experience a problem, enable logging, reproduce the issue,\n" .. + "then share the log files with support.", fill_horizontal = 1, height_in_lines = 2, alignment = 'left', @@ -298,41 +296,59 @@ function PluginInfoDialogSections.sectionsForTopOfDialog(f, propertyTable) }, f:row { - f:radio_button { + f:checkbox { value = bind 'debugEnabled', - checked_value = false, - title = "Logging off", + title = "Enable logging", }, f:spacer { fill_horizontal = 1 }, }, f:row { - f:radio_button { - value = bind 'debugEnabled', - checked_value = true, - title = "Live view in Lightroom (Help → Debug Console)", - }, - f:spacer { fill_horizontal = 1 }, f:push_button { - title = "Open log file", + title = "Open log files", enabled = bind 'debugEnabled', action = function() - LrShell.revealInShell(utils.getLogfilePath()) + LrShell.revealInShell(LrPathUtils.parent(utils.getLogfilePath())) end, }, + f:push_button { + title = "Clear log files", + action = function() + local ok = utils.clearLogFiles() + LrDialogs.message( + "Log file cleared", + ok and "Done." or "Could not clear log file.", + "info" + ) + end, + }, + f:spacer { fill_horizontal = 1 }, }, f:row { - f:checkbox { - value = bind 'debugToFile', - enabled = bind 'debugEnabled', + spacing = f:dialog_spacing(), + f:picture { + value = _PLUGIN:resourceId('icons/email_32.png'), }, - f:static_text { - title = "Also save to log file on disk (recommended for sharing with support)", - alignment = 'left', - fill_horizontal = 1, - width_in_chars = 40, + f:push_button { + title = "Report by email", + action = function() + LrShell.revealInShell(LrPathUtils.parent(utils.getLogfilePath())) + LrHttp.openUrlInBrowser("mailto:contact@fbphotography.uk?subject=PiwigoPublish%20issue&body=Please%20attach%20the%20log%20file.") + end, }, + f:spacer { width = 16 }, + f:picture { + value = _PLUGIN:resourceId('icons/github_32.png'), + }, + f:push_button { + title = "Report via GitHub", + action = function() + LrShell.revealInShell(LrPathUtils.parent(utils.getLogfilePath())) + LrHttp.openUrlInBrowser("https://github.com/Piwigo/PiwigoPublish-lrc-plugin/issues/new") + end, + }, + f:spacer { fill_horizontal = 1 }, }, }, @@ -371,6 +387,14 @@ function PluginInfoDialogSections.sectionsForTopOfDialog(f, propertyTable) end, }, }, + + f:row { + f:checkbox { + value = bind 'clearLogOnReload', + title = "Clear log file on plugin reload", + }, + f:spacer { fill_horizontal = 1 }, + }, }, }, @@ -380,16 +404,12 @@ end -- ************************************************* function PluginInfoDialogSections.endDialog(propertyTable) prefs.debugEnabled = propertyTable.debugEnabled - prefs.debugToFile = propertyTable.debugToFile + prefs.clearLogOnReload = propertyTable.clearLogOnReload prefs.checkUpdatesOnStartup = propertyTable.checkUpdatesOnStartup -- Apply debug settings if prefs.debugEnabled then - if prefs.debugToFile then - log:enable("logfile") - else - log:enable("print") - end + log:enable("logfile") else log:disable() end diff --git a/piwigoPublish.lrplugin/PublishDialogSections.lua b/piwigoPublish.lrplugin/PublishDialogSections.lua index 5e786ff..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 = "", @@ -139,7 +165,6 @@ local function connectionDialog(f, propertyTable, pwInstance) }, -- Username - f:spacer { height = 1 }, f:row { f:static_text { title = "", @@ -161,7 +186,6 @@ local function connectionDialog(f, propertyTable, pwInstance) }, -- Password - f:spacer { height = 1 }, f:row { f:static_text { title = "", @@ -182,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 @@ -942,343 +956,11 @@ local function prefsDialog(f, propertyTable) } end -- --- ************************************************* --- Video Toolkit dialog section (Phase 2A) --- ************************************************* -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)" } - -local function 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 - - return { - title = "Video Settings", - bind_to_object = propertyTable, - - f:group_box { - title = "Video Toolkit", - font = "", - fill_horizontal = 1, - - f:spacer { height = 2 }, - - -- Enable/disable toggle - 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.", - }, - }, - - f:row { - fill_horizontal = 1, - f:checkbox { - title = "Include video files", - fill_horizontal = 1, - value = bind "vtkIncludeVideo", - tooltip = "Include video files in publications. Requires Video Toolkit to be enabled.", - enabled = bind "vtkEnabled", - }, - }, - - f:spacer { height = 4 }, - - -- Preset + poster settings (enabled only when vtkEnabled = true) - f:separator { fill_horizontal = 1 }, - f:row { f:static_text { title = "Encoding Settings", fill_horizontal = 1 } }, - f:column { - fill_horizontal = 1, - enabled = bind "vtkEnabled", - - f:spacer { height = 2 }, - - 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 { height = 2 }, - - f:row { - f:static_text { - title = "Poster thumbnail:", - alignment = 'right', - width = share 'vtk_label_w', - }, - f:checkbox { - title = "Generate poster (JPG)", - fill_horizontal = 1, - value = bind "vtkGeneratePoster", - tooltip = "Extract a JPG thumbnail from the video and upload as representative image.", - }, - }, - - f:row { - f:static_text { - title = "Poster at:", - alignment = 'right', - width = share 'vtk_label_w', - }, - 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 }, - - -- Advanced paths (collapsible group_box) - f:separator { fill_horizontal = 1 }, - f:row { f:static_text { title = "Advanced — Tool Paths", fill_horizontal = 1 } }, - f:column { - fill_horizontal = 1, - enabled = bind "vtkEnabled", - - f:spacer { height = 2 }, - - f:row { - f:static_text { - title = "Toolkit:", - alignment = 'right', - width = share 'vtk_label_w', - }, - f:edit_field { - value = bind "vtkToolkitPath", - placeholder_string = "(auto: /video-toolkit/video_toolkit.py)", - fill_horizontal = 1, - tooltip = "Path to video_toolkit.py. Leave empty to use the bundled toolkit next to the plugin.", - }, - }, - - 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)", - }, - }, - - 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)", - }, - }, - - 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).", - placeholder_string = "(auto-detect)", - }, - }, - - 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)", - }, - }, - - 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 }, - }, - - f:spacer { height = 4 }, - - -- Status + action buttons - f:separator { fill_horizontal = 1 }, - f:row { f:static_text { title = "Status", fill_horizontal = 1 } }, - f:column { - fill_horizontal = 1, - enabled = bind "vtkEnabled", - - f:spacer { height = 2 }, - - f:row { - f:static_text { - title = LrView.bind { - keys = { "vtkEnabled", "vtkPythonPath", "vtkFFmpegPath" }, - operation = function(_, values, _) - if not values.vtkEnabled then - return "Video Toolkit disabled." - 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...", - font = "", - 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:push_button { - title = "Pre-render Now...", - font = "", - width = share 'buttonwidth', - enabled = bind "vtkEnabled", - tooltip = "Pre-process all videos in the current publish service without publishing them.", - action = function(_) - LrDialogs.message("Pre-render", - "Pre-render will be available in a future update.\n\nFor now, videos are processed automatically during publish.", - "info") - end, - }, - }, - - f:spacer { height = 2 }, - }, - }, - } -end - -- ************************************************* function PublishDialogSections.sectionsForTopOfDialog(f, propertyTable) local conDlg = connectionDialog(f, propertyTable) local prefDlg = prefsDialog(f, propertyTable) - local videoDlg = videoDialog(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 diff --git a/piwigoPublish.lrplugin/PublishServiceProvider.lua b/piwigoPublish.lrplugin/PublishServiceProvider.lua index 2ae8093..376bf20 100644 --- a/piwigoPublish.lrplugin/PublishServiceProvider.lua +++ b/piwigoPublish.lrplugin/PublishServiceProvider.lua @@ -41,7 +41,7 @@ return { allowColorSpaces = nil, canExportVideo = true, allowVideoExportPresets = { - { formatID = "original" }, -- LrC ne ré-encode pas ; Video Toolkit gère le transcodage + { formatID = "original" }, -- LrC does not re-encode; Video Toolkit handles transcoding }, supportsCustomSortOrder = true, hidePrintResolution = true, @@ -76,6 +76,7 @@ return { { key = "vtkFFprobePath", default = '' }, { key = "vtkExifToolPath", default = '' }, { key = "vtkPresetsFile", default = '' }, + { key = "vtkHardwareAccel", default = 'auto' }, }, metadataThatTriggersRepublish = function(publishSettings, photoId, fieldName) diff --git a/piwigoPublish.lrplugin/PublishTask.lua b/piwigoPublish.lrplugin/PublishTask.lua index 5b1d676..520c4a4 100644 --- a/piwigoPublish.lrplugin/PublishTask.lua +++ b/piwigoPublish.lrplugin/PublishTask.lua @@ -328,565 +328,45 @@ function PublishTask.processRenderedPhotos(functionContext, exportContext) -- flag to allow sync comments to manage process in PublishTask.getCommentsFromPublishedCollection PWStatusManager.setRenderPhotos(publishService, true) - -- Video upload guard: pre-check before rendering starts - local videoUploadBlocked = false - local serverMaxBytes = nil - local companionAvailable = false - - -- Pre-scan: detect if batch contains videos and check server support BEFORE rendering - local batchVideoCount = 0 + -- Video pre-scan + server support check (delegated to vtk_core) local batchTotalCount = exportSession:countRenditions() - -- videoPhotos[i] = { photo, existingImageId, appliedPreset, republishMode } - -- republishMode : "new" | "re_upload" | "metadata_only" - local videoPhotos = {} - for photo in exportSession:photosToExport() do - local fmt = photo:getRawMetadata("fileFormat") - if fmt == "VIDEO" then - batchVideoCount = batchVideoCount + 1 - -- Detect republication context - 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 - -- Verify image still exists on Piwigo - local checkStatus = PiwigoAPI.checkPhoto(propertyTable, existingImageId) - if not checkStatus.status then - -- Image was deleted from Piwigo → treat as new upload - log:info("PublishTask - video image_id=" .. existingImageId .. " no longer exists on Piwigo, treating as new") - existingImageId = nil - republishMode = "new" - else - appliedPreset = photo:getPropertyForPlugin(_PLUGIN, "pwVideoPreset") or "" - -- 5C: collection override takes priority over service default - local currentPreset = (collectionSettings.vtkPresetOverride and collectionSettings.vtkPresetOverride ~= "") - and collectionSettings.vtkPresetOverride - or ((propertyTable.vtkDefaultPreset and propertyTable.vtkDefaultPreset ~= "") - and propertyTable.vtkDefaultPreset or "medium") - if appliedPreset == "" then - -- No preset recorded → video was never processed by VTK → need full processing - republishMode = "re_upload" - elseif appliedPreset ~= currentPreset then - -- Preset changed → need re-encode (force=true in batch) - republishMode = "re_upload" - else - -- Same preset → still re_upload (VTK cache decides whether to re-encode) - -- processRenderedPhotos is only called for full republish, not metadata-only - republishMode = "re_upload" - end - end - end - - table.insert(videoPhotos, { - photo = photo, - existingImageId = existingImageId, - appliedPreset = appliedPreset, - republishMode = republishMode, - }) - end - end + 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) - - -- Check if user disabled video inclusion in publish settings - if propertyTable.vtkIncludeVideo == false then - log:info("PublishTask - video inclusion disabled by user (vtkIncludeVideo = false)") - videoUploadBlocked = true - for _, vEntry in ipairs(videoPhotos) do - local vName = vEntry.photo:getFormattedMetadata("fileName") or "unknown" - log:info("PublishTask - removing video (disabled by user): " .. vName) - exportSession:removePhoto(vEntry.photo) - end - if batchVideoCount >= batchTotalCount then - log:info("PublishTask - batch contained only videos, all disabled — nothing to render") - 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 - end - end - - -- Check server video support (skip if already blocked by user setting) - local warnings = {} - if not videoUploadBlocked then - 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("PublishTask - 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("PublishTask - 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 - - -- FFmpeg absence is non-blocking, info available via Companion admin page - if cfg.ffmpeg and not cfg.ffmpeg.installed then - log:info("PublishTask - FFmpeg not installed (non-blocking)") - end - else - videoUploadBlocked = true - table.insert(warnings, "- Companion plugin responded but returned no configuration data.") - end - end - end - - if videoUploadBlocked then - -- Remove blocked videos from session BEFORE rendering starts - for _, vEntry in ipairs(videoPhotos) do - local vName = vEntry.photo:getFormattedMetadata("fileName") or "unknown" - log:info("PublishTask - removing blocked video from session: " .. vName) - exportSession:removePhoto(vEntry.photo) - end - -- If batch contained ONLY videos, skip rendering entirely - 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("PublishTask - batch contained only videos, all blocked — nothing to render") - LrDialogs.message("Video Upload Blocked", reason, "critical") - PWStatusManager.setPiwigoBusy(publishService, false) - PWStatusManager.setRenderPhotos(publishService, false) - return - 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 - -- Server allows video — check per-file size BEFORE rendering (only for new/re_upload) - -- Skip size check if VTK is enabled: the variant produced by VTK will be uploaded, not the original - if serverMaxBytes and not propertyTable.vtkEnabled then - local oversizedVideos = {} - for idx = #videoPhotos, 1, -1 do - local vEntry = videoPhotos[idx] - local vPhoto = vEntry.photo - -- metadata_only videos are never uploaded, skip size check - 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("PublishTask - removing oversized video from session: " .. 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 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 : lancement + polling + upload variantes + -- Phase 2C/2D — Video Toolkit (delegated to vtk_core) -- ----------------------------------------------------------------------- - -- vtkResults[i] = { photo, existingImageId, republishMode, variantPath, thumbnailPath, status, error } - local vtkResults = {} - local metadataOnlyVideos = {} -- 4C : videos needing metadata-only update + 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 "Ce fichier est une vidéo" dialog + -- 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 - -- Résoudre les chemins des outils (config manuelle > auto-détection > fallback PATH) - local python = utils.resolveTool(propertyTable.vtkPythonPath, "python") - log:info("PublishTask - python resolved to: " .. python) - local toolkitScript = utils.resolveToolkitPath(propertyTable.vtkToolkitPath, _PLUGIN.path) - log:info("PublishTask - toolkitScript resolved to: " .. toolkitScript) - -- 5C: collection override takes priority over service default - local preset = (collectionSettings.vtkPresetOverride and collectionSettings.vtkPresetOverride ~= "") - and collectionSettings.vtkPresetOverride - or ((propertyTable.vtkDefaultPreset and propertyTable.vtkDefaultPreset ~= "") - and propertyTable.vtkDefaultPreset or "medium") - log:info("PublishTask - video preset effective: " .. preset - .. (collectionSettings.vtkPresetOverride ~= "" and " (collection override)" or " (service default)")) - - -- Fichier statut global pour le polling - local statusFilePath = LrPathUtils.child( - LrPathUtils.getStandardFilePath("temp"), - "piwigoPublish_vtk_status.json" - ) - - -- Construire le fichier batch 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 - -- "new" or "re_upload" : run through toolkit - -- force=true if preset changed (re_upload), false for new (toolkit decides via hash) - table.insert(batchVideos, { - input = filePath, - preset = preset, - force = (vEntry.republishMode == "re_upload"), - }) - end - end - end - - -- Si toutes les vidéos sont metadata_only, skip le toolkit entièrement - if #batchVideos == 0 then - log:info("PublishTask - all videos are metadata-only, skipping Video Toolkit") - else - - local batchData = { - videos = batchVideos, - status_file = statusFilePath, - } - - -- Écrire le fichier batch - local batchFile = io.open(batchFilePath, "w") - if batchFile then - batchFile:write(JSON:encode(batchData)) - batchFile:close() - end - - -- Construire les arguments optionnels - 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 "" - - -- Commande complète — stdout+stderr capturés via --log-file (Python) - local vtkLogPath = LrPathUtils.child(LrPathUtils.getStandardFilePath("temp"), "piwigoPublish_vtk.log") - local cmd = '"' .. python .. '" "' .. toolkitScript .. '"' - .. ' --mode batch' - .. ' --batch-file "' .. batchFilePath .. '"' - .. ' --status-file "' .. statusFilePath .. '"' - .. ' --log-file "' .. vtkLogPath .. '"' - .. ffmpegArg .. exiftoolArg .. presetsArg - - -- Supprimer l'ancien log pour éviter de lire des résultats périmés si VTK crash - if LrFileUtils.exists(vtkLogPath) then - LrFileUtils.delete(vtkLogPath) - end - - -- Écrire un fichier .bat temporaire pour contourner le problème de guillemets - -- imbriqués avec LrTasks.execute sur Windows (cmd /c + guillemets multiples) - 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("PublishTask - VTK command: " .. cmd) - log:info("PublishTask - VTK bat file: " .. batPath) - log:info("PublishTask - VTK log file: " .. vtkLogPath) - -- Log du contenu du batch file pour diagnostic - local bfDiag = io.open(batchFilePath, "r") - if bfDiag then - log:info("PublishTask - VTK batch content: " .. (bfDiag:read("*all") or "")) - bfDiag:close() - end - - -- Configurer la progression LrC pendant le traitement vidéo - progressScope:setCaption("Video Toolkit — Processing " .. batchVideoCount .. " video(s)...") - - -- Inform user that transcoding may take a long time - 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") - - -- Lancer le .bat (LrTasks.execute bloque le thread courant) - local vtkCancelled = false - local vtkExitCode = LrTasks.execute('"' .. batPath .. '"') - log:info("PublishTask - LrTasks.execute returned: " .. tostring(vtkExitCode) .. " (type=" .. type(vtkExitCode) .. ")") - - -- Si le log file n'existe pas encore, attendre un peu (VTK peut écrire avec un léger délai) - if not LrFileUtils.exists(vtkLogPath) then - log:info("PublishTask - waiting for VTK log file...") - for _ = 1, 20 do -- max 10 secondes - LrTasks.sleep(0.5) - if LrFileUtils.exists(vtkLogPath) then break end - end - end - - -- Lire les résultats depuis le log file (source de vérité — indépendant du code de sortie) - -- Note: LrTasks.execute peut retourner 1 même si Python sort en 0 (bug Windows/Python 3.14) - if vtkCancelled then - log:info("PublishTask - VTK cancelled by user, videos will be skipped") - LrDialogs.message("Video Toolkit Cancelled", - "Publication cancelled during video processing.\n\nVideos have not been uploaded.", - "warning") - else - if vtkExitCode ~= 0 and vtkExitCode ~= nil then - log:warn("PublishTask - VTK exit code: " .. tostring(vtkExitCode) .. " — checking log file for actual status") - end - -- Lire le JSON depuis le log file (contient les résultats complets) - local vtkOutput = nil - local lf = io.open(vtkLogPath, "r") - if lf then - local raw = lf:read("*all") or "" - lf:close() - local ok, parsed = pcall(function() return JSON:decode(raw) end) - if ok and parsed then - vtkOutput = parsed - else - log:warn("PublishTask - VTK log parse failed: " .. raw:sub(1, 500)) - end - else - log:warn("PublishTask - VTK log not found: " .. vtkLogPath) - end - - if vtkOutput and vtkOutput.status == "ok" and vtkOutput.results then - -- Succès — construire vtkResults indexé par chemin source - 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, - 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 - -- Échec réel : pas de log, JSON invalide, ou status != "ok" - local reason = (vtkOutput and vtkOutput.status) or "no output" - log:warn("PublishTask - VTK failed: " .. reason) - LrDialogs.message("Video Toolkit Error", - "Video Toolkit failed.\n\nDetails in:\n" .. vtkLogPath - .. "\n\nVideos will be skipped.", - "critical") - end - end - - progressScope:setCaption("Publishing to Piwigo...") - progressScope:setPortionComplete(0, 100) - - end -- if #batchVideos == 0 / else - - -- ----------------------------------------------------------------------- - -- Upload des variantes VTK AVANT la boucle renditions - -- (pour pouvoir appeler recordPublishedPhotoId dans la boucle) - -- ----------------------------------------------------------------------- - if #vtkResults > 0 then - log:info("PublishTask - uploading " .. #vtkResults .. " video variant(s)") - progressScope:setCaption("Uploading video variants...") - - 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("PublishTask - skipping video (toolkit error): " .. vName .. " — " .. errMsg) - table.insert(vtkFailedVideos, "• " .. vName .. "\n " .. errMsg) - else - log:info("PublishTask - uploading video 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( - "PublishTask - video %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("PublishTask - video " .. vName .. " → addSimple upload") - uploadStatus = PiwigoAPI.updateGallery(propertyTable, vr.variantPath, metaData) - end - - if uploadStatus.status then - local imageId = uploadStatus.remoteid or "" - log:info("PublishTask - video variant uploaded, image_id=" .. imageId) - - -- Upload du poster - if vr.thumbnailPath and vr.thumbnailPath ~= "" - and LrFileUtils.exists(vr.thumbnailPath) then - if companionAvailable then - log:info("PublishTask - uploading poster: " .. vr.thumbnailPath) - progressScope:setCaption("Uploading poster: " .. vName) - local posterStatus = PiwigoAPI.setRepresentative( - propertyTable, imageId, vr.thumbnailPath) - if posterStatus.status then - log:info("PublishTask - poster set for image_id=" .. imageId) - else - log:warn("PublishTask - poster upload failed: " - .. (posterStatus.statusMsg or "")) - end - end - end - - -- Set video dimensions on Piwigo (via Companion) - if companionAvailable and vr.videoWidth > 0 and vr.videoHeight > 0 then - log:info("PublishTask - setting video info: " - .. vr.videoWidth .. "x" .. vr.videoHeight - .. " size=" .. vr.videoSize) - PiwigoAPI.setVideoInfo( - propertyTable, imageId, - vr.videoWidth, vr.videoHeight, vr.videoSize) - end - - -- Mettre à jour les métadonnées sur Piwigo - metaData.Remoteid = imageId - PiwigoAPI.updateMetadata(propertyTable, vPhoto, metaData) - - -- Stocker le résultat pour la boucle renditions - vr.uploadedImageId = imageId - vr.uploadedRemoteUrl = uploadStatus.remoteurl or "" - - -- Stocker les métadonnées custom - 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) - - -- Mark video as "Published" in LrC (no rendition available — removed via removePhoto) - -- addPhotoByRemoteId works for both new and existing published photos - catalog:withWriteAccessDo("Mark video published", function() - publishedCollection:addPhotoByRemoteId( - vPhoto, tostring(imageId), - uploadStatus.remoteurl or "", true) - log:info("PublishTask - marked video published: " .. vName .. " (image_id=" .. imageId .. ")") - end, { timeout = 5 }) - else - log:warn("PublishTask - video variant 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 - + -- 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) @@ -1066,81 +546,11 @@ function PublishTask.processRenderedPhotos(functionContext, exportContext) end -- end if not kwBlocked end - -- ----------------------------------------------------------------------- - -- Phase 4C — Metadata-only video updates (videos already on Piwigo, same preset) - -- ----------------------------------------------------------------------- - if #metadataOnlyVideos > 0 then - log:info("PublishTask - updating metadata for " .. #metadataOnlyVideos .. " video(s) (metadata-only)") - 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("PublishTask - metadata-only update for image_id=" .. imageId .. " (" .. vName .. ")") - local metaData = utils.getPhotoMetadata(propertyTable, vPhoto) - metaData.Albumid = albumId - metaData.Remoteid = imageId - PiwigoAPI.updateMetadata(propertyTable, vPhoto, metaData) - log:info("PublishTask - metadata updated for image_id=" .. imageId) - - -- setVideoInfo depuis le fichier .vtk (dimensions de la variante déjà transcodée) - 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 - -- Déduire width/height depuis resolution si absent - 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("PublishTask - setVideoInfo (metadata-only) image_id=" - .. imageId .. " " .. vw .. "x" .. vh .. " size=" .. vs) - PiwigoAPI.setVideoInfo(propertyTable, imageId, vw, vh, vs) - end - else - log:info("PublishTask - no .vtk variant data for preset=" .. preset .. " (" .. vName .. ")") - end - else - log:info("PublishTask - .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("PublishTask - marked metadata-only video published: " .. vName) - break - end - end - end, { timeout = 5 }) - else - log:warn("PublishTask - metadata-only: no image_id for " .. vName .. ", skipping") - end - end - 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) diff --git a/piwigoPublish.lrplugin/UIHelpers.lua b/piwigoPublish.lrplugin/UIHelpers.lua index 218dac8..6a46b8d 100644 --- a/piwigoPublish.lrplugin/UIHelpers.lua +++ b/piwigoPublish.lrplugin/UIHelpers.lua @@ -29,36 +29,25 @@ UIHelpers = {} -- 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', }, }, } diff --git a/piwigoPublish.lrplugin/icons/email_32.png b/piwigoPublish.lrplugin/icons/email_32.png new file mode 100644 index 0000000000000000000000000000000000000000..822890474ae3bcbd586adaa14abc0ee9683bf528 GIT binary patch literal 652 zcmV;70(1R|P)N!;O8v%FXBI=h}4j;Kg*HgmRA zzU7rY_xh-ZhRpD%4&8da@kcKwYJmx^dBNV(J$V7fC6hLs!4p-aJAesj`D~!&-+wc) zR!&%V=hMN7Tyf3wT$gM*xs{QTiJ>~t-N?yC{l0hR^mkjQX9}G?|3Dd!BS>=q%<^A9 z7}&BpSKbGfmJFO6Y=1XROb~WSoB0VCGXEDf#K|Q#FM0U)?>`1s7UuuEX6NE_1n~}l zS^f!Vc@fldRu`Og6 mF!4)Z;1JgV!3RuQ#gZpG0$SLY-?y!tlRQ==sXd7AdK=k;rxR7 ze;NI*2$dN`)KM#Ex)piCvg|@i0S>s7-J2#*a5SqISmqmfoOq60Z{p_9lo7tzzq@l{3oKh{J{}jcQp+mY7K~w(jCkHN zF-Y0yWYnEb$bu4DLUz)b3RPRKa3z<93X!ICh<2793?*eV44qCEt%203A71hlHA zb&dsLjAdPSAdFZrx{wq87C0F}tV+AA3l#-5&}-0M#id!`YeNc}da9@jRpUSDNOCN$ zU`-ghKc-od01C1>4Xt?syDY1A=YR)kL6%sh!{q{z&Y*&T{{=*?p+b8%VP&@0>lp%D zvpZrBc)Ih(wyeUMuv@ZgS^|P6D1e@pMmYzO1PJA!c0WKzM9oC2LTz`il3R)5Ugc|6 zrDfSdZW))lIC#S8Zn<&{IfSc}KFgwWtqHr~hrLQiEg~%mPQvLH@O09ixh^PIoe_!N z&~>`7NNCZv!3?T|aF`Kzkr=|xz1wR(wc}~a4C}U}Be`vK&4=JR%>*xpgI+_&bhrHR zLS1+rmVi%Uhf94}2Y8_bpNH&~K4{=K?)!cq8mUG?@;sAkK-sh;3ngpPXW~L6Ux{`? z7>{QyJ|syT@C!U%Ddk#WUC+QGT0*!d|BCkHd2T4R)yx*eb0PY^nuu;4SOF??XrXXr zP_oOWz6*FP56HZT15kb82tp?y@k?Ov?rzXOe~AKNAfsp~du9yE_%NCEL=wY6A6HdC zXZZx$X2b`166TbIoZ$FTcfOxB+$`*dL$}}NbWu#c!?NI_7LMlF;dhlGt+mV++#Pv* gfA*ZZzEwKuKYXg58uW0kZU6uP07*qoM6N<$f`Q7Yj{pDw literal 0 HcmV?d00001 diff --git a/piwigoPublish.lrplugin/utils.lua b/piwigoPublish.lrplugin/utils.lua index 9af8b0a..1d5ca02 100644 --- a/piwigoPublish.lrplugin/utils.lua +++ b/piwigoPublish.lrplugin/utils.lua @@ -1291,6 +1291,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 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 index 2a7fae4..d1cb77f 100644 --- a/video-toolkit/INSTALL.md +++ b/video-toolkit/INSTALL.md @@ -1,39 +1,39 @@ # Video Toolkit — Installation -## Dépendances +## Dependencies -### Requis +### Required - **Python** 3.8+ -- **FFmpeg** 5.0+ (transccodage vidéo + analyse avec ffprobe) +- **FFmpeg** 5.0+ (video transcoding + analysis via ffprobe) -### Optionnel +### Optional -- **ExifTool** 12+ (copie de métadonnées — sans lui, les métadonnées ne sont pas copiées) +- **ExifTool** 12+ (metadata copying — without it, GPS, date and keywords are not copied to the compressed file) -## Installation par système +## Installation by Platform ### Windows -#### Via winget (recommandé) -```bash -winget install ffmpeg -winget install exiftool +#### Via winget (recommended — built into Windows 11) +```cmd +winget install Python.Python.3 +winget install Gyan.FFmpeg +winget install OliverBetz.ExifTool ``` #### Via Chocolatey -```bash -choco install ffmpeg -choco install exiftool +```cmd +choco install python ffmpeg exiftool ``` -#### Manuel -1. Télécharger FFmpeg : https://ffmpeg.org/download.html - - Extraire le ZIP dans `C:\ffmpeg\` - - Ajouter `C:\ffmpeg\bin` à la variable PATH (ou configurer dans le toolkit) +#### 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. Télécharger ExifTool : https://exiftool.org/ - - Mettre le `.exe` dans `C:\exiftool\` (ou un dossier dans PATH) +2. Download ExifTool: https://exiftool.org/ + - Place `exiftool.exe` in `C:\exiftool\` (or any folder that is in PATH) ### macOS @@ -43,14 +43,14 @@ brew install ffmpeg brew install exiftool ``` -### Linux (Debian/Ubuntu) +### Linux (Debian / Ubuntu) ```bash sudo apt update -sudo apt install python3 ffmpeg exiftool +sudo apt install python3 ffmpeg libimage-exiftool-perl ``` -### Linux (Fedora/RHEL) +### Linux (Fedora / RHEL) ```bash sudo dnf install python3 ffmpeg perl-Image-ExifTool @@ -62,32 +62,28 @@ sudo dnf install python3 ffmpeg perl-Image-ExifTool sudo pacman -S python ffmpeg perl-image-exiftool ``` -## Configuration du toolkit +## Configuring the Toolkit -### Mode 1 : Auto-détection (recommandé) +### Option 1: Auto-detection (recommended) -Les outils sont détectés automatiquement si : -- Ils sont dans le PATH système -- Ou aux emplacements courants (Windows: `C:\ffmpeg\bin\ffmpeg.exe`, etc.) +Tools are detected automatically if they are: +- In the system PATH +- Or at common installation locations (Windows: `C:\ffmpeg\bin\ffmpeg.exe`, etc.) -Lancez le toolkit en mode interactif pour vérifier : +**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 -# Menu "Outils" affichera l'état de chaque outil +# Tools menu shows the status of each dependency ``` -### Mode 2 : Configurer manuellement - -Si l'auto-détection échoue, configurez les chemins dans le menu "Paramètres" du toolkit interactif. +### Option 2: Configure manually -```bash -python video_toolkit.py -# → Paramètres (option 4) -# → Modifier FFmpeg path / FFprobe path / ExifTool path -``` +If auto-detection fails, set the paths directly in Lightroom under **Video Settings → Advanced — Tool Paths**. -Ou éditer directement `~/.piwigoPublish/video-toolkit.json` : +Alternatively, edit `~/.piwigoPublish/video-toolkit.json`: ```json { @@ -97,15 +93,15 @@ Ou éditer directement `~/.piwigoPublish/video-toolkit.json` : } ``` -## Vérification +## Verification ```bash python video_toolkit.py --mode probe --input sample_video.mp4 ``` -Doit retourner un JSON avec résolution, durée, codecs, etc. +Should return a JSON object with resolution, duration, codecs, etc. ```bash python video_toolkit.py -# Menu interactif → Outils (option 3) pour vérifier l'état des dépendances +# 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/requirements.txt b/video-toolkit/requirements.txt deleted file mode 100644 index 594a4b3..0000000 --- a/video-toolkit/requirements.txt +++ /dev/null @@ -1,12 +0,0 @@ -# Video Toolkit — Dépendances Python -# Aucune dépendance externe requise. -# Tous les modules utilisés sont de la bibliothèque standard Python 3.8+ -# -# Outils système requis (non-Python) : -# - FFmpeg 5.0+ (ffmpeg + ffprobe) -# - ExifTool 12+ (optionnel — copie de métadonnées) -# -# Installation outils : -# Windows : winget install ffmpeg OR https://ffmpeg.org/download.html -# macOS : brew install ffmpeg exiftool -# Linux : apt install ffmpeg libimage-exiftool-perl diff --git a/video-toolkit/src/__init__.py b/video-toolkit/src/__init__.py index ddf3689..55852a2 100644 --- a/video-toolkit/src/__init__.py +++ b/video-toolkit/src/__init__.py @@ -1 +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 index 37dedd3..0119dff 100644 --- a/video-toolkit/src/cli.py +++ b/video-toolkit/src/cli.py @@ -59,6 +59,8 @@ def build_parser() -> argparse.ArgumentParser: 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 @@ -110,7 +112,7 @@ def run_process(args: argparse.Namespace, cfg: Config) -> int: force = getattr(args, "force", False) thumbnail_only = getattr(args, "thumbnail_only", False) - processor = _build_processor(cfg) + processor = _build_processor(cfg, getattr(args, "hwaccel", None)) global_sf = None if args.status_file: @@ -150,6 +152,10 @@ def _progress(pct: int) -> None: "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]) @@ -190,7 +196,7 @@ def run_batch(args: argparse.Namespace, cfg: Config) -> int: global_sf = GlobalStatusFile(status_file) global_sf.update(STATE_PROCESSING, progress=0, total=total, done=0) - processor = _build_processor(cfg) + processor = _build_processor(cfg, getattr(args, "hwaccel", None)) # Construire les jobs depuis le batch JSON jobs = [] @@ -235,6 +241,10 @@ def _batch_progress(done: int, total_: int, current: str) -> None: } 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, "") @@ -691,6 +701,35 @@ def _menu_tools(self) -> None: 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") @@ -723,6 +762,7 @@ def _menu_config(self) -> None: 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 = self.cfg.get("hardware_accel", "auto") poster_status = f"{self.c.OK}activé{self.c.RESET}" if generate_poster else f"{self.c.DIM}désactivé{self.c.RESET}" @@ -735,12 +775,13 @@ def _menu_config(self) -> None: 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-7): ")).strip() + choice = input(self.c.prompt("Votre choix (0-8): ")).strip() if choice == "0": return @@ -757,6 +798,8 @@ def _menu_config(self) -> None: 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) @@ -834,14 +877,40 @@ def _edit_presets_file(self) -> None: 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) -> VideoProcessor: +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 = 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", @@ -850,6 +919,7 @@ def _build_processor(cfg: Config) -> VideoProcessor: thumbnail_timestamp_pct=cfg.get("poster_timestamp_pct", 10), thumbnail_max_width=cfg.get("thumbnail_width", 1280), copy_metadata=cfg.get("copy_metadata", True), + hwaccel_mode=effective_hwaccel, ) @@ -883,8 +953,9 @@ def _suggest_preset(width: int, height: int) -> str: 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) + 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: @@ -941,8 +1012,8 @@ def _print_install_instructions(c: Colors, tool: str, tool_name: str) -> None: print(f" {c.DIM}Aucune instruction d'installation pour {tool_name}{c.RESET}\n") return - platform = "Windows" if sys.platform == "win32" else ("macOS" if sys.platform == "darwin" else "Linux") - methods = instructions.get(platform, instructions.get("Linux", [])) + 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: diff --git a/video-toolkit/src/config.py b/video-toolkit/src/config.py index 6247d71..a15b771 100644 --- a/video-toolkit/src/config.py +++ b/video-toolkit/src/config.py @@ -44,6 +44,7 @@ class Config: "thumbnail_height": 720, "thumbnail_quality": 85, "copy_metadata": True, + "hardware_accel": "auto", "vtk_dir_name": ".vtk", } @@ -85,7 +86,7 @@ def get_presets_file(self) -> Path: 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. + Ordre : config → PATH → emplacements courants (Windows/macOS/Linux). Retourne le chemin trouvé ou None. """ configured = self._data.get(f"{tool}_path", "").strip() @@ -97,12 +98,17 @@ def resolve_tool(self, tool: str) -> str | None: if found: return found - # Emplacements courants Windows + # Emplacements courants par plateforme if sys.platform == "win32": candidates = _windows_candidates(tool) - for candidate in candidates: - if Path(candidate).is_file(): - return candidate + 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 @@ -151,3 +157,51 @@ def _windows_candidates(tool: str) -> list[str]: ], } 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 index 48d156f..eec31eb 100644 --- a/video-toolkit/src/ffmpeg.py +++ b/video-toolkit/src/ffmpeg.py @@ -16,7 +16,9 @@ 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 @@ -49,8 +51,10 @@ class ThumbnailResult: class FFmpeg: """Wrapper autour de l'exécutable ffmpeg pour transcode + miniature.""" - def __init__(self, ffmpeg_path: str = "ffmpeg"): + 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 @@ -84,6 +88,7 @@ def transcode( 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 @@ -91,8 +96,12 @@ def transcode( 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 + input_path, output_path, preset, scale_filter, + is_hdr=is_hdr, hw_config=hw_config, ) if dry_run: @@ -105,7 +114,18 @@ def transcode( size=0, ) - self._run(cmd, src_duration, progress_callback) + 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 @@ -179,6 +199,7 @@ def check_available(self) -> bool: [self.binary, "-version"], capture_output=True, timeout=5, + **SUBPROCESS_FLAGS, ) return r.returncode == 0 except (FileNotFoundError, subprocess.TimeoutExpired): @@ -192,6 +213,7 @@ def get_version(self) -> str | None: capture_output=True, text=True, timeout=5, + **SUBPROCESS_FLAGS, ) if r.returncode == 0: first_line = r.stdout.splitlines()[0] @@ -213,17 +235,14 @@ def _build_transcode_cmd( 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: - # 1. Convert to linear light in BT.2020 space (zscale) - # 2. Tonemap from linear HDR to linear SDR (hable algorithm) - # 3. Convert from BT.2020 to BT.709 color space - # 4. Scale to target resolution + # HDR → SDR tonemap pipeline (always CPU — hw_config is None here) vf = ( "zscale=t=linear:npl=100," "format=gbrpf32le," @@ -234,16 +253,35 @@ def _build_transcode_cmd( f"{scale_filter}" ) else: - vf = scale_filter + 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)] - cmd = [ - self.binary, - "-i", input_path, + # 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", preset.video_codec, + "-c:v", video_codec, "-profile:v", preset.h264_profile, "-level:v", "4.0", - "-crf", str(preset.crf), + ] + cmd += quality_args + cmd += [ "-b:v", f"{vb}k", "-maxrate", f"{int(vb * 1.2)}k", "-bufsize", f"{vb * 2}k", @@ -318,6 +356,7 @@ def _run( text=True, encoding="utf-8", errors="replace", + **SUBPROCESS_FLAGS, ) except FileNotFoundError: raise FFmpegError(f"ffmpeg introuvable : '{self.binary}'") @@ -352,6 +391,7 @@ def _run_simple(self, cmd: list[str]) -> None: encoding="utf-8", errors="replace", timeout=60, + **SUBPROCESS_FLAGS, ) except FileNotFoundError: raise FFmpegError(f"ffmpeg introuvable : '{self.binary}'") diff --git a/video-toolkit/src/ffprobe.py b/video-toolkit/src/ffprobe.py index 0f84f87..b66302c 100644 --- a/video-toolkit/src/ffprobe.py +++ b/video-toolkit/src/ffprobe.py @@ -12,6 +12,8 @@ from dataclasses import dataclass from pathlib import Path +from . import SUBPROCESS_FLAGS + # --------------------------------------------------------------------------- # Dataclass VideoInfo @@ -106,6 +108,7 @@ def probe(self, input_path: str | Path) -> VideoInfo: capture_output=True, text=True, timeout=30, + **SUBPROCESS_FLAGS, ) except FileNotFoundError: raise ProbeError(f"ffprobe introuvable : '{self.binary}'") @@ -131,6 +134,7 @@ def check_available(self) -> bool: [self.binary, "-version"], capture_output=True, timeout=5, + **SUBPROCESS_FLAGS, ) return r.returncode == 0 except (FileNotFoundError, subprocess.TimeoutExpired): @@ -144,6 +148,7 @@ def get_version(self) -> str | None: capture_output=True, text=True, timeout=5, + **SUBPROCESS_FLAGS, ) if r.returncode == 0: first_line = r.stdout.splitlines()[0] 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 index 0b06dff..67a1f43 100644 --- a/video-toolkit/src/metadata.py +++ b/video-toolkit/src/metadata.py @@ -18,6 +18,8 @@ from dataclasses import dataclass, field from pathlib import Path +from . import SUBPROCESS_FLAGS + # --------------------------------------------------------------------------- # Tags copiés par défaut @@ -140,6 +142,7 @@ def copy( encoding="utf-8", errors="replace", timeout=60, + **SUBPROCESS_FLAGS, ) except FileNotFoundError: return ExifToolResult( @@ -199,6 +202,7 @@ def extract(self, source_path: str | Path) -> VideoMetadata | None: encoding="utf-8", errors="replace", timeout=30, + **SUBPROCESS_FLAGS, ) except (FileNotFoundError, subprocess.TimeoutExpired): return None @@ -223,6 +227,7 @@ def check_available(self) -> bool: [self.binary, "-ver"], capture_output=True, timeout=5, + **SUBPROCESS_FLAGS, ) return r.returncode == 0 except (FileNotFoundError, subprocess.TimeoutExpired): @@ -236,6 +241,7 @@ def get_version(self) -> str | None: capture_output=True, text=True, timeout=5, + **SUBPROCESS_FLAGS, ) if r.returncode == 0: return r.stdout.strip() diff --git a/video-toolkit/src/processor.py b/video-toolkit/src/processor.py index 3c624ff..4443560 100644 --- a/video-toolkit/src/processor.py +++ b/video-toolkit/src/processor.py @@ -47,6 +47,8 @@ class ProcessResult: 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) # --------------------------------------------------------------------------- @@ -68,8 +70,9 @@ def __init__( thumbnail_timestamp_pct: int = 10, thumbnail_max_width: int = 1280, copy_metadata: bool = True, + hwaccel_mode: str = "auto", ): - self._ffmpeg = FFmpeg(ffmpeg_path) + self._ffmpeg = FFmpeg(ffmpeg_path, hwaccel_mode=hwaccel_mode) self._ffprobe = FFprobe(ffprobe_path) self._exiftool = ExifTool(exiftool_path) @@ -164,6 +167,16 @@ def process( 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), @@ -175,6 +188,8 @@ def process( 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 --- @@ -294,6 +309,16 @@ def _transcode_progress(pct: int) -> None: 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), @@ -305,6 +330,8 @@ def _transcode_progress(pct: int) -> None: size=variant_size, thumbnail_size=thumb_size, skipped=False, + orig=orig_meta, + conv=conv_meta, ) # ------------------------------------------------------------------- @@ -354,6 +381,19 @@ def process_batch( # 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( diff --git a/video-toolkit/src/ui.py b/video-toolkit/src/ui.py index 6b2e245..3469806 100644 --- a/video-toolkit/src/ui.py +++ b/video-toolkit/src/ui.py @@ -8,6 +8,7 @@ import os import sys import ctypes +from pathlib import Path # --------------------------------------------------------------------------- @@ -40,11 +41,14 @@ def _detect_color_support() -> bool: 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 : vérifier isatty - return hasattr(sys.stdout, "isatty") and sys.stdout.isatty() + # Unix : isatty déjà vérifié + return True # --------------------------------------------------------------------------- @@ -214,8 +218,11 @@ def print_files_generated( self.print_section_header("FICHIERS GÉNÉRÉS") if output_dir: rel = output_dir - if plugin_path and output_dir.startswith(plugin_path): - rel = output_dir[len(plugin_path):].lstrip("/\\") + 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})") @@ -227,7 +234,13 @@ def print_files_generated( # --------------------------------------------------------------------------- def clear_screen(): - os.system("cls" if sys.platform == "win32" else "clear") + 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..."): diff --git a/video-toolkit/tests/test_hasher.py b/video-toolkit/tests/test_hasher.py index 06b16a4..4d1b104 100644 --- a/video-toolkit/tests/test_hasher.py +++ b/video-toolkit/tests/test_hasher.py @@ -101,6 +101,6 @@ def test_empty_file(self): finally: p.unlink() - def test_nonexistent_file_raises(self): + def test_nonexistent_file_raises(self, tmp_path): with pytest.raises((FileNotFoundError, OSError)): - partial_hash(Path("/nonexistent/file.mp4")) + partial_hash(tmp_path / "__nonexistent__.mp4") diff --git a/video-toolkit/video_toolkit.py b/video-toolkit/video_toolkit.py index c26a3c7..5e0ba83 100644 --- a/video-toolkit/video_toolkit.py +++ b/video-toolkit/video_toolkit.py @@ -37,6 +37,8 @@ def main() -> int: 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 @@ -78,6 +80,15 @@ def main() -> int: 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:") @@ -88,6 +99,7 @@ def main() -> int: "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": From da8476a34aed5054ce8526dc113514497a21ee5b Mon Sep 17 00:00:00 2001 From: Gotcha26 Date: Thu, 19 Feb 2026 14:10:31 +0100 Subject: [PATCH 39/51] Upgrade Companion --- lightroom_companion/main.inc.php | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/lightroom_companion/main.inc.php b/lightroom_companion/main.inc.php index 68ea316..abca46a 100644 --- a/lightroom_companion/main.inc.php +++ b/lightroom_companion/main.inc.php @@ -1,10 +1,11 @@ Date: Thu, 19 Feb 2026 18:22:55 +0100 Subject: [PATCH 40/51] Update Companion with multi-language and better intergration with Bootstrap Darkroom theme --- VALIDATION-CHECKLIST.md | 111 ------- lightroom_companion/admin.php | 15 +- lightroom_companion/admin.tpl | 144 ++++----- lightroom_companion/language/en_UK/index.php | 4 + .../language/en_UK/plugin.lang.php | 94 ++++++ lightroom_companion/language/fr_FR/index.php | 4 + .../language/fr_FR/plugin.lang.php | 94 ++++++ lightroom_companion/language/index.php | 4 + lightroom_companion/main.inc.php | 299 ++++++++++++++---- 9 files changed, 520 insertions(+), 249 deletions(-) delete mode 100644 VALIDATION-CHECKLIST.md create mode 100644 lightroom_companion/language/en_UK/index.php create mode 100644 lightroom_companion/language/en_UK/plugin.lang.php create mode 100644 lightroom_companion/language/fr_FR/index.php create mode 100644 lightroom_companion/language/fr_FR/plugin.lang.php create mode 100644 lightroom_companion/language/index.php diff --git a/VALIDATION-CHECKLIST.md b/VALIDATION-CHECKLIST.md deleted file mode 100644 index 7753a3e..0000000 --- a/VALIDATION-CHECKLIST.md +++ /dev/null @@ -1,111 +0,0 @@ -# PiwigoPublish — Checklist de validation manuelle (Phase 5D) - -> Validation à effectuer dans Lightroom Classic avec un service Piwigo de test. -> Cocher chaque cas avant de considérer une release stable. - ---- - -## Prérequis - -- [ ] Piwigo installé et accessible (serveur local ou distant) -- [ ] Plugin `lightroom-companion` installé et activé sur Piwigo -- [ ] Video Toolkit installé (`python video_toolkit.py --mode probe` retourne du JSON) -- [ ] FFmpeg disponible (auto-détecté ou configuré) -- [ ] Au moins 3 photos JPG de test + 2 vidéos MP4 de test dans le catalogue LrC - ---- - -## 1. Configuration du service - -| # | Scénario | Résultat attendu | OK | -|---|----------|-----------------|-----| -| 1.1 | Configurer le service avec URL/user/pass valides | Connexion verte dans le panneau | ☐ | -| 1.2 | Activer "Enable Video Toolkit" | Sections preset et outils apparaissent | ☐ | -| 1.3 | Laisser les chemins outils vides → cliquer "Check Tools" | Détection auto + dialog "auto-detected at: ..." | ☐ | -| 1.4 | Saisir un chemin Python invalide → cliquer "Check Tools" | Dialog d'erreur avec commande d'installation | ☐ | -| 1.5 | Sélectionner preset "Medium (720p)" comme défaut service | Valeur persistée à la réouverture du manager | ☐ | - ---- - -## 2. Publication de photos seules (régression) - -| # | Scénario | Résultat attendu | OK | -|---|----------|-----------------|-----| -| 2.1 | Publier 3 photos JPG dans un album | Photos visibles dans Piwigo, marquées "Published" dans LrC | ☐ | -| 2.2 | Re-publier une photo modifiée (métadonnées) | Mise à jour titre/description sur Piwigo sans erreur | ☐ | -| 2.3 | Supprimer une photo publiée dans LrC | Photo supprimée de Piwigo | ☐ | -| 2.4 | Publier avec filtre de mots-clés actif | Seules les photos correspondant au filtre sont publiées | ☐ | - ---- - -## 3. Publication de vidéos — premier envoi - -| # | Scénario | Résultat attendu | OK | -|---|----------|-----------------|-----| -| 3.1 | Publier 1 vidéo MP4 (preset Medium) | Variante 720p visible dans Piwigo, poster affiché, vidéo marquée "Published" | ☐ | -| 3.2 | Vérifier les métadonnées custom dans LrC | `pwVideoPreset = "medium"`, `pwImageURL` rempli, `pwUploadDate` correct | ☐ | -| 3.3 | Publier 2 vidéos en même batch | Les deux uploadées, progression affichée pour chaque | ☐ | -| 3.4 | Publier batch mixte 2 photos + 1 vidéo | Photos et vidéo toutes publiées, types traités séparément | ☐ | -| 3.5 | Vérifier le poster dans Piwigo | Miniature personnalisée visible (pas l'icône générique vidéo) | ☐ | - ---- - -## 4. Publication de vidéos — republication - -| # | Scénario | Résultat attendu | OK | -|---|----------|-----------------|-----| -| 4.1 | Re-publier une vidéo sans changement (même preset) | Metadata-only : pas de re-upload, titre/description mis à jour | ☐ | -| 4.2 | Changer le preset service (Medium → Large) puis republier | Re-encode forcé, nouvelle variante 1080p uploadée | ☐ | -| 4.3 | Vérifier que `pwVideoPreset` est mis à jour après 4.2 | Valeur = "large" dans les métadonnées custom LrC | ☐ | -| 4.4 | Re-publier sans changement après 4.2 | Metadata-only (pas de re-encode), log confirme | ☐ | - ---- - -## 5. Override preset par collection (5C) - -| # | Scénario | Résultat attendu | OK | -|---|----------|-----------------|-----| -| 5.1 | Ouvrir les settings d'un album → section "Video Preset Override" visible | Popup "Use service default" + 6 presets | ☐ | -| 5.2 | Sélectionner "Small (480p)" comme override → publier une vidéo | Variante 480p créée et uploadée (pas 720p) | ☐ | -| 5.3 | Vérifier `pwVideoPreset = "small"` dans les métadonnées | Valeur correcte stockée | ☐ | -| 5.4 | Revenir à "Use service default" → republier | Re-encode avec le preset service (Medium), pas Small | ☐ | -| 5.5 | Deux albums avec presets différents, publier une vidéo dans chacun | Chaque album utilise son preset respectif | ☐ | - ---- - -## 6. Cas d'erreur et gestion (5A) - -| # | Scénario | Résultat attendu | OK | -|---|----------|-----------------|-----| -| 6.1 | Publier vidéo avec plugin Companion désactivé sur Piwigo | Dialog "Companion plugin not installed", vidéos retirées du batch, photos publiées | ☐ | -| 6.2 | Publier vidéo avec Video Toolkit désactivé dans les settings | Vidéos ignorées silencieusement (ou message), photos publiées | ☐ | -| 6.3 | Annuler pendant le traitement toolkit (barre de progression) | Dialog "Publication cancelled during video processing" | ☐ | -| 6.4 | Simuler échec toolkit (Python introuvable après config) | Dialog "Video Toolkit Error (exit code X)" | ☐ | -| 6.5 | Publier vidéo metadata-only avec image_id manquant | Dialog warning avec nom du fichier concerné | ☐ | - ---- - -## 7. Upload chunked (vidéos volumineuses) - -| # | Scénario | Résultat attendu | OK | -|---|----------|-----------------|-----| -| 7.1 | Configurer server max = 50 MB, publier vidéo de 80 MB | Upload chunked déclenché (log "→ chunked upload"), vidéo visible sur Piwigo | ☐ | -| 7.2 | Publier vidéo sous la limite | Upload standard addSimple (log "→ addSimple upload") | ☐ | - ---- - -## 8. Auto-détection outils (5B) - -| # | Scénario | Résultat attendu | OK | -|---|----------|-----------------|-----| -| 8.1 | Vider le champ Python + cliquer "Check Tools" | Python auto-détecté, chemin affiché dans le dialog | ☐ | -| 8.2 | Publier une vidéo sans aucun chemin configuré | Python auto-détecté et utilisé, publication réussie | ☐ | -| 8.3 | Vérifier le log plugin (`LrC/Plug-in Log`) | Ligne "python resolved to: C:/..." présente | ☐ | - ---- - -## Notes de test - -- Logs plugin LrC : **Aide → Plug-in Log** → chercher `PublishTask` -- Dossier `.vtk/` créé à côté des vidéos originales (variantes + cache hash) -- En cas d'échec inattendu, joindre le log complet au rapport de bug diff --git a/lightroom_companion/admin.php b/lightroom_companion/admin.php index 0503485..5a411b6 100644 --- a/lightroom_companion/admin.php +++ b/lightroom_companion/admin.php @@ -5,15 +5,20 @@ global $template, $conf, $page; +// ========================================================================= +// Language +// ========================================================================= +load_language('plugin.lang', dirname(__FILE__) . '/'); + // ========================================================================= // Tabs // ========================================================================= $page['tab'] = isset($_GET['tab']) ? $_GET['tab'] : 'video'; $tabsheet = new tabsheet(); -$tabsheet->add('video', 'Video', get_admin_plugin_menu_link(dirname(__FILE__).'/admin.php') . '&tab=video'); -$tabsheet->add('server', 'Server', get_admin_plugin_menu_link(dirname(__FILE__).'/admin.php') . '&tab=server'); -$tabsheet->add('settings', 'Settings', get_admin_plugin_menu_link(dirname(__FILE__).'/admin.php') . '&tab=settings'); +$tabsheet->add('video', l10n('lrc_tab_video'), get_admin_plugin_menu_link(dirname(__FILE__).'/admin.php') . '&tab=video'); +$tabsheet->add('server', l10n('lrc_tab_server'), get_admin_plugin_menu_link(dirname(__FILE__).'/admin.php') . '&tab=server'); +$tabsheet->add('settings', l10n('lrc_tab_settings'), get_admin_plugin_menu_link(dirname(__FILE__).'/admin.php') . '&tab=settings'); $tabsheet->select($page['tab']); $tabsheet->assign(); @@ -63,7 +68,7 @@ $conf['companion_config'] = json_encode($new_config); $action_status = 'ok'; - $action_message = 'Settings saved.'; + $action_message = l10n('lrc_settings_saved'); } // ========================================================================= @@ -217,7 +222,7 @@ function companion_is_videojs($str) 'LRC_PHP_POST' => $php['post_max_size'], 'LRC_PHP_MAXTIME' => $php['max_execution_time'], 'LRC_PHP_EXEC' => $exec_available, - 'LRC_PHP_EXEC_NOTE' => $exec_available ? '' : 'exec() is disabled — contact your hosting provider', + 'LRC_PHP_EXEC_NOTE' => $exec_available ? '' : l10n('lrc_exec_disabled_note'), // Graphics 'LRC_GD' => $gfx_gd, diff --git a/lightroom_companion/admin.tpl b/lightroom_companion/admin.tpl index 16af3fb..4ca8bb6 100644 --- a/lightroom_companion/admin.tpl +++ b/lightroom_companion/admin.tpl @@ -94,50 +94,50 @@
- Video support is fully active - Upload enabled & VideoJS plugin active — videos can be published from Lightroom. + {'lrc_video_fully_active'|translate} + {'lrc_video_fully_active_sub'|translate}
{else}
!
- Video support is not fully configured - Check the items below and fix each one. + {'lrc_video_not_configured'|translate} + {'lrc_video_not_configured_sub'|translate}
{/if} {* --- Upload Piwigo --- *} -
Video Upload (Piwigo)
+
{'lrc_section_video_upload'|translate}
- + - + - + @@ -149,53 +149,53 @@ - + -

Adds upload_form_all_types = true and video extensions (mp4, m4v, ogg, ogv, webm) to local/config/config.inc.php.

+

{'lrc_enable_video_note'|translate}

{elseif $LRC_COMPANION_BLOCK} - + -

Removes the Companion block from local/config/config.inc.php. Video uploads will no longer be allowed.

+

{'lrc_disable_video_note'|translate}

{/if} {elseif not $LRC_VIDEO_READY} -

Config file is not writable. Add manually to local/config/config.inc.php:

+

{'lrc_config_not_writable'|translate}

$conf['upload_form_all_types'] = true; $conf['file_ext'] = array_merge($conf['picture_ext'], array('mp4', 'm4v', 'ogg', 'ogv', 'webm'));
{/if} {* --- VideoJS plugin --- *} -
VideoJS Plugin
+
{'lrc_section_videojs'|translate}
Upload status{'lrc_upload_status'|translate} {if $LRC_VIDEO_READY} - Ready + {'lrc_ready'|translate} {else} - Not configured + {'lrc_not_configured'|translate} {/if}
All file types{'lrc_all_file_types'|translate} {if $LRC_UPLOAD_ALL} - Enabled + {'lrc_enabled'|translate} {else} - Disabled + {'lrc_disabled'|translate} {/if}
Video extensions{'lrc_video_extensions'|translate} {if $LRC_VIDEO_EXTS} {$LRC_VIDEO_EXTS} {else} - None configured + {'lrc_none_configured'|translate} {/if}
{if $LRC_VJS_INSTALLED} - + - + {else} - + {/if}
Plugin{'lrc_plugin'|translate} {$LRC_VJS_NAME}
Status{'lrc_status'|translate} {if $LRC_VJS_ACTIVE} - Active + {'lrc_active'|translate} {else} - Installed but INACTIVE + {'lrc_installed_inactive'|translate} {/if}
VideoJSNot installed{'lrc_not_installed'|translate}
{if not $LRC_VJS_INSTALLED} -

Install and activate the VideoJS plugin from Piwigo administration for in-gallery video playback.

+

{'lrc_videojs_install_note'|translate}

{elseif not $LRC_VJS_ACTIVE} -

Activate VideoJS in Piwigo administration (Plugins menu) for video playback to work.

+

{'lrc_videojs_activate_note'|translate}

{/if} {/if}{* end tab video *} @@ -206,7 +206,7 @@ $conf['file_ext'] = array_merge($conf['picture_ext'], array('mp4', 'm4v', 'ogg', {if $LRC_TAB eq 'server'} {* --- CLI Tools --- *} -
Video & Media Tools
+
{'lrc_section_media_tools'|translate}
@@ -226,26 +226,26 @@ $conf['file_ext'] = array_merge($conf['picture_ext'], array('mp4', 'm4v', 'ogg',
FFmpeg
{if $LRC_FFMPEG_NO_TPL} -

Without FFmpeg, videos will upload but Piwigo will not generate a custom thumbnail for them.

+

{'lrc_ffmpeg_no_note'|translate}

{/if} {* --- Server & PHP --- *} -
Server & PHP
+
{'lrc_section_server_php'|translate}
- - - + + + - + @@ -255,7 +255,7 @@ $conf['file_ext'] = array_merge($conf['picture_ext'], array('mp4', 'm4v', 'ogg', {/if} {* --- Graphics --- *} -
Graphics Libraries
+
{'lrc_section_graphics'|translate}
OS{$LRC_OS}
Web Server{$LRC_WEBSERVER}
PHP Version{$LRC_PHP_VERSION}
{'lrc_os'|translate}{$LRC_OS}
{'lrc_web_server'|translate}{$LRC_WEBSERVER}
{'lrc_php_version'|translate}{$LRC_PHP_VERSION}
upload_max_filesize{$LRC_PHP_UPLOAD}
post_max_size{$LRC_PHP_POST}
memory_limit{$LRC_PHP_MEM}
max_execution_time{$LRC_PHP_MAXTIME}s
exec() available{'lrc_exec_available'|translate} {if $LRC_PHP_EXEC} - Yes + {'lrc_yes'|translate} {else} - No + {'lrc_no'|translate} {/if}
@@ -263,7 +263,7 @@ $conf['file_ext'] = array_merge($conf['picture_ext'], array('mp4', 'm4v', 'ogg', {if $LRC_GD} {$LRC_GD} {else} - Not available + {'lrc_not_available'|translate} {/if} @@ -273,32 +273,32 @@ $conf['file_ext'] = array_merge($conf['picture_ext'], array('mp4', 'm4v', 'ogg', {if $LRC_IMAGICK} {$LRC_IMAGICK} {else} - Not available + {'lrc_not_available'|translate} {/if}
GD
{* --- Piwigo --- *} -
Piwigo Gallery
+
{'lrc_section_piwigo'|translate}
- + - + - + @@ -315,8 +315,8 @@ $conf['file_ext'] = array_merge($conf['picture_ext'], array('mp4', 'm4v', 'ogg',
!
- GD library not available - Thumbnail processing requires the PHP GD extension. Posters will be stored as-is. + {'lrc_gd_not_available'|translate} + {'lrc_gd_not_available_sub'|translate}
{/if} @@ -326,111 +326,111 @@ $conf['file_ext'] = array_merge($conf['picture_ext'], array('mp4', 'm4v', 'ogg', {* --- Thumbnail size --- *} -
Video Thumbnail
+
{'lrc_section_thumbnail'|translate}
Version{$LRC_PIWIGO_VER}
{'lrc_version'|translate}{$LRC_PIWIGO_VER}
Guest theme{'lrc_guest_theme'|translate} {$LRC_PUBLIC_THEME} {if $LRC_PARENT_THEME neq $LRC_PUBLIC_THEME} - ↳ parent: {$LRC_PARENT_THEME} + ↳ {'lrc_parent'|translate}: {$LRC_PARENT_THEME} {/if}
Config file writable{'lrc_config_writable'|translate} {if $LRC_CFG_WRITABLE} - Yes + {'lrc_yes'|translate} {else} - No + {'lrc_no'|translate} {/if}
- + - +
Max size (px){'lrc_max_size'|translate} px - (longest side) + ({'lrc_longest_side'|translate})
No upscale{'lrc_no_upscale'|translate}
{* --- Film strip --- *} -
Film Strip Effect
+
{'lrc_section_filmstrip'|translate}
- +
35mm film border{'lrc_filmstrip_label'|translate}
-

The thumbnail becomes square with black letterbox and 35mm-style sprocket holes on the sides.

+

{'lrc_filmstrip_note'|translate}

{* --- Overlays --- *} -
Overlays
+
{'lrc_section_overlays'|translate}
- + - + - + - + - +
Video icon (corner){'lrc_video_icon'|translate} {if not $LRC_HAS_VIDEO_ICON} - (missing: assets/video-icon.png) + ({'lrc_missing_asset'|translate}: assets/video-icon.png) {/if}
Icon position{'lrc_icon_position'|translate}   
Play button (center){'lrc_play_button'|translate} - drawn natively, no PNG needed + {'lrc_play_native_note'|translate}
Play button size{'lrc_play_size'|translate} % - of the shortest side (5–50%) + {'lrc_play_size_note'|translate}
Play button opacity{'lrc_play_opacity'|translate} % - transparency of the overlay (10–100%) + {'lrc_play_opacity_note'|translate}
-

Place your custom PNG file (with transparency) in the lightroom_companion/assets/ folder for the video icon overlay.

+

{'lrc_overlay_asset_note'|translate}

- +
diff --git a/lightroom_companion/language/en_UK/index.php b/lightroom_companion/language/en_UK/index.php new file mode 100644 index 0000000..060a204 --- /dev/null +++ b/lightroom_companion/language/en_UK/index.php @@ -0,0 +1,4 @@ +upload_form_all_types = true and video extensions (mp4, m4v, ogg, ogv, webm) to local/config/config.inc.php.'; +$lang['lrc_disable_video'] = 'Disable Video Support'; +$lang['lrc_disable_video_note'] = 'Removes the Companion block from local/config/config.inc.php. Video uploads will no longer be allowed.'; +$lang['lrc_config_not_writable'] = 'Config file is not writable. Add manually to local/config/config.inc.php:'; + +// == VIDEOJS == +$lang['lrc_section_videojs'] = 'VideoJS Plugin'; +$lang['lrc_plugin'] = 'Plugin'; +$lang['lrc_status'] = 'Status'; +$lang['lrc_active'] = 'Active'; +$lang['lrc_installed_inactive'] = 'Installed but INACTIVE'; +$lang['lrc_not_installed'] = 'Not installed'; +$lang['lrc_videojs_install_note'] = 'Install and activate the VideoJS plugin from Piwigo administration for in-gallery video playback.'; +$lang['lrc_videojs_activate_note'] = 'Activate VideoJS in Piwigo administration (Plugins menu) for video playback to work.'; + +// == TAB SERVER == +$lang['lrc_section_media_tools'] = 'Video & Media Tools'; +$lang['lrc_ffmpeg_no_note'] = 'Without FFmpeg, videos will upload but Piwigo will not generate a custom thumbnail for them.'; +$lang['lrc_section_server_php'] = 'Server & PHP'; +$lang['lrc_os'] = 'OS'; +$lang['lrc_web_server'] = 'Web Server'; +$lang['lrc_php_version'] = 'PHP Version'; +$lang['lrc_exec_available'] = 'exec() available'; +$lang['lrc_yes'] = 'Yes'; +$lang['lrc_no'] = 'No'; +$lang['lrc_exec_disabled_note'] = 'exec() is disabled — contact your hosting provider'; +$lang['lrc_section_graphics'] = 'Graphics Libraries'; +$lang['lrc_not_available'] = 'Not available'; +$lang['lrc_section_piwigo'] = 'Piwigo Gallery'; +$lang['lrc_version'] = 'Version'; +$lang['lrc_guest_theme'] = 'Guest theme'; +$lang['lrc_parent'] = 'parent'; +$lang['lrc_config_writable'] = 'Config file writable'; + +// == TAB SETTINGS == +$lang['lrc_gd_not_available'] = 'GD library not available'; +$lang['lrc_gd_not_available_sub'] = 'Thumbnail processing requires the PHP GD extension. Posters will be stored as-is.'; +$lang['lrc_section_thumbnail'] = 'Video Thumbnail'; +$lang['lrc_max_size'] = 'Max size (px)'; +$lang['lrc_longest_side'] = 'longest side'; +$lang['lrc_no_upscale'] = 'No upscale'; +$lang['lrc_no_enlarge'] = "Don't enlarge small images"; +$lang['lrc_section_filmstrip'] = 'Film Strip Effect'; +$lang['lrc_filmstrip_label'] = '35mm film border'; +$lang['lrc_filmstrip_option'] = 'Add perforated film borders (square output)'; +$lang['lrc_filmstrip_note'] = 'The thumbnail becomes square with black letterbox and 35mm-style sprocket holes on the sides.'; +$lang['lrc_section_overlays'] = 'Overlays'; +$lang['lrc_video_icon'] = 'Video icon (corner)'; +$lang['lrc_video_icon_option'] = 'Show video file icon'; +$lang['lrc_missing_asset'] = 'missing'; +$lang['lrc_icon_position'] = 'Icon position'; +$lang['lrc_bottom_right'] = 'Bottom-right'; +$lang['lrc_bottom_left'] = 'Bottom-left'; +$lang['lrc_play_button'] = 'Play button (center)'; +$lang['lrc_play_button_option'] = 'Show play button overlay'; +$lang['lrc_play_native_note'] = 'drawn natively, no PNG needed'; +$lang['lrc_play_size'] = 'Play button size'; +$lang['lrc_play_size_note'] = 'of the shortest side (5–50%)'; +$lang['lrc_play_opacity'] = 'Play button opacity'; +$lang['lrc_play_opacity_note'] = 'transparency of the overlay (10–100%)'; +$lang['lrc_overlay_asset_note'] = 'Place your custom PNG file (with transparency) in the lightroom_companion/assets/ folder for the video icon overlay.'; +$lang['lrc_save_settings'] = 'Save Settings'; +$lang['lrc_settings_saved'] = 'Settings saved.'; + +// == VIDEO META (picture page) == +$lang['lrc_video_original'] = 'Video (original)'; +$lang['lrc_video_converted'] = 'Video (converted)'; diff --git a/lightroom_companion/language/fr_FR/index.php b/lightroom_companion/language/fr_FR/index.php new file mode 100644 index 0000000..060a204 --- /dev/null +++ b/lightroom_companion/language/fr_FR/index.php @@ -0,0 +1,4 @@ +upload_form_all_types = true et les extensions vidéo (mp4, m4v, ogg, ogv, webm) dans local/config/config.inc.php.'; +$lang['lrc_disable_video'] = 'Désactiver le support vidéo'; +$lang['lrc_disable_video_note'] = 'Supprime le bloc Companion de local/config/config.inc.php. L\'upload de vidéos ne sera plus autorisé.'; +$lang['lrc_config_not_writable'] = 'Le fichier de configuration n\'est pas modifiable. Ajoutez manuellement dans local/config/config.inc.php :'; + +// == VIDEOJS == +$lang['lrc_section_videojs'] = 'Plugin VideoJS'; +$lang['lrc_plugin'] = 'Plugin'; +$lang['lrc_status'] = 'Statut'; +$lang['lrc_active'] = 'Actif'; +$lang['lrc_installed_inactive'] = 'Installé mais INACTIF'; +$lang['lrc_not_installed'] = 'Non installé'; +$lang['lrc_videojs_install_note'] = 'Installez et activez le plugin VideoJS depuis l\'administration Piwigo pour la lecture vidéo dans la galerie.'; +$lang['lrc_videojs_activate_note'] = 'Activez VideoJS dans l\'administration Piwigo (menu Plugins) pour que la lecture vidéo fonctionne.'; + +// == ONGLET SERVEUR == +$lang['lrc_section_media_tools'] = 'Outils vidéo & média'; +$lang['lrc_ffmpeg_no_note'] = 'Sans FFmpeg, les vidéos seront uploadées mais Piwigo ne générera pas de vignette personnalisée.'; +$lang['lrc_section_server_php'] = 'Serveur & PHP'; +$lang['lrc_os'] = 'OS'; +$lang['lrc_web_server'] = 'Serveur web'; +$lang['lrc_php_version'] = 'Version PHP'; +$lang['lrc_exec_available'] = 'exec() disponible'; +$lang['lrc_yes'] = 'Oui'; +$lang['lrc_no'] = 'Non'; +$lang['lrc_exec_disabled_note'] = 'exec() est désactivé — contactez votre hébergeur'; +$lang['lrc_section_graphics'] = 'Bibliothèques graphiques'; +$lang['lrc_not_available'] = 'Non disponible'; +$lang['lrc_section_piwigo'] = 'Galerie Piwigo'; +$lang['lrc_version'] = 'Version'; +$lang['lrc_guest_theme'] = 'Thème visiteur'; +$lang['lrc_parent'] = 'parent'; +$lang['lrc_config_writable'] = 'Fichier config modifiable'; + +// == ONGLET RÉGLAGES == +$lang['lrc_gd_not_available'] = 'Bibliothèque GD non disponible'; +$lang['lrc_gd_not_available_sub'] = 'Le traitement des vignettes nécessite l\'extension PHP GD. Les posters seront stockés tels quels.'; +$lang['lrc_section_thumbnail'] = 'Vignette vidéo'; +$lang['lrc_max_size'] = 'Taille max (px)'; +$lang['lrc_longest_side'] = 'côté le plus long'; +$lang['lrc_no_upscale'] = 'Pas d\'agrandissement'; +$lang['lrc_no_enlarge'] = 'Ne pas agrandir les petites images'; +$lang['lrc_section_filmstrip'] = 'Effet pellicule'; +$lang['lrc_filmstrip_label'] = 'Bordure pellicule 35mm'; +$lang['lrc_filmstrip_option'] = 'Ajouter des bordures perforées (sortie carrée)'; +$lang['lrc_filmstrip_note'] = 'La vignette devient carrée avec un letterbox noir et des perforations style 35mm sur les côtés.'; +$lang['lrc_section_overlays'] = 'Superpositions'; +$lang['lrc_video_icon'] = 'Icône vidéo (coin)'; +$lang['lrc_video_icon_option'] = 'Afficher l\'icône fichier vidéo'; +$lang['lrc_missing_asset'] = 'manquant'; +$lang['lrc_icon_position'] = 'Position de l\'icône'; +$lang['lrc_bottom_right'] = 'Bas-droite'; +$lang['lrc_bottom_left'] = 'Bas-gauche'; +$lang['lrc_play_button'] = 'Bouton lecture (centre)'; +$lang['lrc_play_button_option'] = 'Afficher le bouton lecture'; +$lang['lrc_play_native_note'] = 'dessiné nativement, pas de PNG nécessaire'; +$lang['lrc_play_size'] = 'Taille bouton lecture'; +$lang['lrc_play_size_note'] = 'du côté le plus court (5–50%)'; +$lang['lrc_play_opacity'] = 'Opacité bouton lecture'; +$lang['lrc_play_opacity_note'] = 'transparence de la superposition (10–100%)'; +$lang['lrc_overlay_asset_note'] = 'Placez votre fichier PNG personnalisé (avec transparence) dans le dossier lightroom_companion/assets/ pour la superposition d\'icône vidéo.'; +$lang['lrc_save_settings'] = 'Enregistrer les réglages'; +$lang['lrc_settings_saved'] = 'Réglages enregistrés.'; + +// == META VIDÉO (page photo) == +$lang['lrc_video_original'] = 'Vidéo (originale)'; +$lang['lrc_video_converted'] = 'Vidéo (convertie)'; diff --git a/lightroom_companion/language/index.php b/lightroom_companion/language/index.php new file mode 100644 index 0000000..c3299b6 --- /dev/null +++ b/lightroom_companion/language/index.php @@ -0,0 +1,4 @@ +set_prefilter('picture', 'companion_inject_sidebar'); - } - else - { - $template->set_prefilter('picture', 'companion_inject_cards'); - } + companion_inject_bdr_js($template, $orig, $conv); break; case 'default': case 'elegant': @@ -734,7 +730,6 @@ function companion_picture_video_meta() $template->set_prefilter('picture', 'companion_inject_default'); break; default: - // Try BDR cards first, fall back to default if anchor not found $template->set_prefilter('picture', 'companion_inject_auto'); break; } @@ -789,60 +784,175 @@ function companion_get_bdr_layout() return 'cards'; } +/** + * BDR: inject video metadata via JavaScript. + * set_prefilter doesn't work on BDR sub-templates ({include file='...'}) + * because Smarty resolves them by filename, not by Piwigo handle. + */ +function companion_inject_bdr_js($template, $orig, $conv) +{ + if (empty($orig) && empty($conv)) return; + + $label_orig = l10n('lrc_video_original'); + $label_conv = l10n('lrc_video_converted'); + + // Build HTML for cards layout (dl/dt/dd with Bootstrap grid) + $html_cards = '
' . $label_orig . '
' + . '
' . $orig . '
'; + if (!empty($conv)) + { + $html_cards .= '
' . $label_conv . '
' + . '
' . $conv . '
'; + } + + // Build HTML for sidebar layout (dt/dd) + $html_sidebar = '
' . $label_orig . '
' . $orig . '
'; + if (!empty($conv)) + { + $html_sidebar .= '
' . $label_conv . '
' . $conv . '
'; + } + + // Build HTML for tabs layout (tr/th/td) + $html_tabs = '
' . $label_orig . '' . $orig . '
' . $label_conv . '' . $conv . '
{\'lrc_video_original\'|translate}{$VTK_VIDEO_ORIG}
{\'lrc_video_converted\'|translate}{$VTK_VIDEO_CONV}
{$LRC_PUBLIC_THEME} {if $LRC_PARENT_THEME neq $LRC_PUBLIC_THEME} - ↳ {'lrc_parent'|translate}: {$LRC_PARENT_THEME} + ↳ {'lrc_parent'|translate}: {$LRC_PARENT_THEME} {/if}
{'lrc_max_size'|translate} px - ({'lrc_longest_side'|translate}) + min="50" max="1280" class="lrc-input-sm"> px + ({'lrc_longest_side'|translate})
{'lrc_play_size'|translate} % - {'lrc_play_size_note'|translate} + value="{$LRC_CFG.overlay_play_size|default:20}" class="lrc-input-xs"> % + {'lrc_play_size_note'|translate}
{'lrc_play_opacity'|translate} % - {'lrc_play_opacity_note'|translate} + value="{$LRC_CFG.overlay_play_opacity|default:100}" class="lrc-input-xs"> % + {'lrc_play_opacity_note'|translate}
@@ -437,3 +407,18 @@ $conf['file_ext'] = array_merge($conf['picture_ext'], array('mp4', 'm4v', 'ogg', {/if}{* end tab settings *}
+ + diff --git a/lightroom_companion/assets/admin.css b/lightroom_companion/assets/admin.css new file mode 100644 index 0000000..fc6f4c8 --- /dev/null +++ b/lightroom_companion/assets/admin.css @@ -0,0 +1,125 @@ +/* === Lightroom Companion — variables thème === */ + +/* ---- Animation pellicule ---- */ +@keyframes lrc-film-scroll { + from { transform: translateX(0); } + to { transform: translateX(-50%); } +} +#lrc-film-overlay { + flex-direction: column; + align-items: center; + gap: 14px; + padding: 20px 0 14px; + margin-bottom: 8px; + overflow: hidden; +} +.lrc-film-strip { + width: 100%; + overflow: hidden; + background: #1a1a1a; + border-radius: 4px; + padding: 6px 0; +} +.lrc-film-track { + display: flex; + width: max-content; + animation: lrc-film-scroll 1.4s linear infinite; +} +.lrc-film-frame { + display: flex; + flex-direction: column; + justify-content: space-between; + width: 52px; + height: 36px; + background: #2a2a2a; + border: 1px solid #444; + border-radius: 2px; + margin: 0 3px; + padding: 3px 0; + align-items: center; + flex-shrink: 0; +} +.lrc-film-hole { + display: block; + width: 10px; + height: 7px; + background: #1a1a1a; + border-radius: 2px; +} +.lrc-film-label { + margin: 0; + font-style: italic; + color: var(--lrc-color-note); + font-size: 0.9em; +} + +:root, +[data-lrc-theme="clear"] { + --lrc-color-section-border : #ccc; + --lrc-color-section-text : #444; + --lrc-color-label : #555; + --lrc-color-note : #666; + --lrc-color-pre-bg : #f5f5f5; + --lrc-color-pre-border : #ddd; + --lrc-status-bg-ok : #edfaed; + --lrc-status-bg-err : #fdecea; + --lrc-status-border-ok : #2a9d2a; + --lrc-status-border-err : #c0392b; +} +[data-lrc-theme="dark"] { + --lrc-color-section-border : #555; + --lrc-color-section-text : #ccc; + --lrc-color-label : #aaa; + --lrc-color-note : #888; + --lrc-color-pre-bg : #1e1e1e; + --lrc-color-pre-border : #444; + --lrc-status-bg-ok : #1a2e1a; + --lrc-status-bg-err : #2e1a1a; + --lrc-status-border-ok : #2a9d2a; + --lrc-status-border-err : #c0392b; +} + +.lrc-wrap { max-width: 820px; } +.lrc-section { margin: 20px 0 6px; font-size: 1.05em; font-weight: bold; + border-bottom: 2px solid var(--lrc-color-section-border); + padding-bottom: 3px; color: var(--lrc-color-section-text); } +.lrc-table { border-collapse: collapse; width: 100%; margin-bottom: 4px; } +.lrc-table td { padding: 4px 6px; vertical-align: top; } +.lrc-label { width: 230px; font-weight: bold; color: var(--lrc-color-label); white-space: nowrap; } +.lrc-ok { color: #2a9d2a; font-weight: bold; } +.lrc-err { color: #c0392b; font-weight: bold; } +.lrc-warn { color: #e67e22; font-weight: bold; } +.lrc-note { font-size: 0.87em; color: var(--lrc-color-note); font-style: italic; + padding: 2px 6px 6px 240px; } +.lrc-action { margin: 14px 0 4px; } +.lrc-pre { background: var(--lrc-color-pre-bg); border: 1px solid var(--lrc-color-pre-border); + padding: 8px 12px; font-family: monospace; font-size: 0.88em; white-space: pre-wrap; } + +/* Status banner (onglet Video) */ +.lrc-status-banner { + display: flex; align-items: center; gap: 16px; + padding: 14px 18px; border-radius: 4px; margin: 16px 0; + border-left: 4px solid var(--lrc-status-border-ok); + background: var(--lrc-status-bg-ok); +} +.lrc-status-banner.lrc-banner-err { + border-color: var(--lrc-status-border-err); + background: var(--lrc-status-bg-err); +} +.lrc-status-icon { font-size: 2em; line-height: 1; } +.lrc-status-text { flex: 1; } +.lrc-status-text strong { display: block; font-size: 1.1em; margin-bottom: 2px; } +.lrc-status-text small { color: var(--lrc-color-note); } + +/* Divers éléments extraits des styles inline */ +.lrc-version-small { font-size: 0.55em; font-style: italic; font-weight: normal; } +.lrc-action-message { margin: 10px 0; } +.lrc-btn-danger { background: #c0392b; } +.lrc-err-config { margin-top: 10px; } +.lrc-parent-theme { margin-left: 8px; } +.lrc-input-hint { color: var(--lrc-color-note); font-size: 0.87em; margin-left: 8px; } +.lrc-input-sm { width: 80px; } +.lrc-input-xs { width: 60px; } +.lrc-warn-asset { font-size: 0.87em; margin-left: 8px; } +.lrc-note-inline { margin-left: 6px; } +.lrc-note-inline-lg { margin-left: 8px; font-size: 0.87em; } diff --git a/lightroom_companion/language/en_UK/plugin.lang.php b/lightroom_companion/language/en_UK/plugin.lang.php index cf22887..7e0f600 100644 --- a/lightroom_companion/language/en_UK/plugin.lang.php +++ b/lightroom_companion/language/en_UK/plugin.lang.php @@ -24,6 +24,7 @@ $lang['lrc_disabled'] = 'Disabled'; $lang['lrc_video_extensions'] = 'Video extensions'; $lang['lrc_none_configured'] = 'None configured'; +$lang['lrc_video_activating'] = 'Activating video support…'; $lang['lrc_enable_video'] = 'Enable Video Support'; $lang['lrc_enable_video_note'] = 'Adds upload_form_all_types = true and video extensions (mp4, m4v, ogg, ogv, webm) to local/config/config.inc.php.'; $lang['lrc_disable_video'] = 'Disable Video Support'; diff --git a/lightroom_companion/language/fr_FR/plugin.lang.php b/lightroom_companion/language/fr_FR/plugin.lang.php index 32ed23f..736b597 100644 --- a/lightroom_companion/language/fr_FR/plugin.lang.php +++ b/lightroom_companion/language/fr_FR/plugin.lang.php @@ -24,6 +24,7 @@ $lang['lrc_disabled'] = 'Désactivé'; $lang['lrc_video_extensions'] = 'Extensions vidéo'; $lang['lrc_none_configured'] = 'Aucune configurée'; +$lang['lrc_video_activating'] = 'Activation du support vidéo…'; $lang['lrc_enable_video'] = 'Activer le support vidéo'; $lang['lrc_enable_video_note'] = 'Ajoute upload_form_all_types = true et les extensions vidéo (mp4, m4v, ogg, ogv, webm) dans local/config/config.inc.php.'; $lang['lrc_disable_video'] = 'Désactiver le support vidéo'; diff --git a/lightroom_companion/main.inc.php b/lightroom_companion/main.inc.php index 36d9668..3203cf8 100644 --- a/lightroom_companion/main.inc.php +++ b/lightroom_companion/main.inc.php @@ -1,9 +1,9 @@ 'ok', 'message' => 'Video support has been enabled. Video extensions (mp4, m4v, ogg, ogv, webm) are now allowed.', @@ -340,35 +343,31 @@ function companion_disable_video_support($params, &$service) $content = file_get_contents($config_path); - $pos = strpos($content, $marker); - if ($pos === false) + if (strpos($content, $marker) === false) { return array('status' => 'already_configured', 'message' => 'Companion block not found — nothing to remove.'); } - // Remove from the blank line just before the marker to the end of the block. - // The block ends at the last semicolon line after the marker. - // Strategy: find the newline before $pos (the blank separator line), remove everything from there to end of block. - // We remove: optional preceding \n, then marker line + all following lines until the next empty line or EOF. - $block_start = $pos; - // Walk back to include the preceding blank line (\n\n before marker) - if ($block_start >= 2 && substr($content, $block_start - 1, 1) === "\n") - $block_start--; - - // Find end of block: scan forward until blank line or end of string - $block_end = $pos + strlen($marker); - $len = strlen($content); - while ($block_end < $len) + // Split into lines, remove every line that belongs to the Companion block + // (the marker line + all non-empty lines following it). + $lines = explode("\n", $content); + $out = array(); + $skip = false; + foreach ($lines as $line) { - $nl = strpos($content, "\n", $block_end); - if ($nl === false) { $block_end = $len; break; } - $line = substr($content, $block_end, $nl - $block_end + 1); - $block_end = $nl + 1; - if (trim($line) === '') break; // blank line = end of block + if (strpos($line, $marker) !== false) + { + $skip = true; // start skipping from the marker line + continue; + } + if ($skip) + { + if (trim($line) === '') { $skip = false; } // blank line ends the block + continue; + } + $out[] = $line; } - - $content = substr($content, 0, $block_start) . substr($content, $block_end); - $content = rtrim($content) . "\n"; + $content = rtrim(implode("\n", $out)) . "\n"; $written = @file_put_contents($config_path, $content); if ($written === false) @@ -376,6 +375,9 @@ function companion_disable_video_support($params, &$service) return array('status' => 'error', 'message' => 'Failed to write to ' . $config_path); } + if (function_exists('opcache_invalidate')) + opcache_invalidate($config_path, true); + return array('status' => 'ok', 'message' => 'Video support has been disabled. The Companion block has been removed from local/config/config.inc.php.'); } From 46d80a15285b96ff70b32bb67d93e5b6c2d7f971 Mon Sep 17 00:00:00 2001 From: Gotcha26 Date: Thu, 5 Mar 2026 21:42:57 +0100 Subject: [PATCH 43/51] Cleaning up. --- lightroom_companion/DESCRIPTION.txt | 6 - lightroom_companion/admin.php | 277 ---- lightroom_companion/admin.tpl | 424 ----- lightroom_companion/assets/README.txt | 13 - lightroom_companion/assets/admin.css | 125 -- lightroom_companion/language/en_UK/index.php | 4 - .../language/en_UK/plugin.lang.php | 95 -- lightroom_companion/language/fr_FR/index.php | 4 - .../language/fr_FR/plugin.lang.php | 95 -- lightroom_companion/language/index.php | 4 - lightroom_companion/main.inc.php | 1450 ----------------- 11 files changed, 2497 deletions(-) delete mode 100644 lightroom_companion/DESCRIPTION.txt delete mode 100644 lightroom_companion/admin.php delete mode 100644 lightroom_companion/admin.tpl delete mode 100644 lightroom_companion/assets/README.txt delete mode 100644 lightroom_companion/assets/admin.css delete mode 100644 lightroom_companion/language/en_UK/index.php delete mode 100644 lightroom_companion/language/en_UK/plugin.lang.php delete mode 100644 lightroom_companion/language/fr_FR/index.php delete mode 100644 lightroom_companion/language/fr_FR/plugin.lang.php delete mode 100644 lightroom_companion/language/index.php delete mode 100644 lightroom_companion/main.inc.php diff --git a/lightroom_companion/DESCRIPTION.txt b/lightroom_companion/DESCRIPTION.txt deleted file mode 100644 index 664fdf8..0000000 --- a/lightroom_companion/DESCRIPTION.txt +++ /dev/null @@ -1,6 +0,0 @@ -Companion plugin for the PiwigoPublish Lightroom plugin. Exposes server diagnostics, provides automatic video upload configuration, extended video metadata storage, and includes an administration page. - -It is a management interface that links Piwigo and Lightroom in order to use videos. It acts as a gateway between the systems. -It works in tandem. It's useless if you don't publish videos through Lightroom. - -Compatible with default themes as well as Bootstrap Darkroom. \ No newline at end of file diff --git a/lightroom_companion/admin.php b/lightroom_companion/admin.php deleted file mode 100644 index 918cf59..0000000 --- a/lightroom_companion/admin.php +++ /dev/null @@ -1,277 +0,0 @@ -add('video', l10n('lrc_tab_video'), get_admin_plugin_menu_link(dirname(__FILE__).'/admin.php') . '&tab=video'); -$tabsheet->add('server', l10n('lrc_tab_server'), get_admin_plugin_menu_link(dirname(__FILE__).'/admin.php') . '&tab=server'); -$tabsheet->add('settings', l10n('lrc_tab_settings'), get_admin_plugin_menu_link(dirname(__FILE__).'/admin.php') . '&tab=settings'); -$tabsheet->select($page['tab']); -$tabsheet->assign(); - -// ========================================================================= -// Handle POST action: Enable Video Support -// ========================================================================= -$action_status = null; -$action_message = null; - -if (isset($_POST['action']) && $_POST['action'] === 'enable_video_support') -{ - check_pwg_token(); - $dummy_service = null; - companion_enable_video_support(array(), $dummy_service); - redirect(get_admin_plugin_menu_link(dirname(__FILE__).'/admin.php') . '&tab=video'); -} - -if (isset($_POST['action']) && $_POST['action'] === 'disable_video_support') -{ - check_pwg_token(); - $dummy_service = null; - companion_disable_video_support(array(), $dummy_service); - redirect(get_admin_plugin_menu_link(dirname(__FILE__).'/admin.php') . '&tab=video'); -} - -if (isset($_POST['action']) && $_POST['action'] === 'save_settings') -{ - check_pwg_token(); - - $new_config = companion_get_all_config(); - - $max_size = (int)($_POST['thumb_max_size'] ?? 350); - $new_config['thumb_max_size'] = max(50, min(1280, $max_size)); - $new_config['thumb_no_upscale'] = isset($_POST['thumb_no_upscale']); - $new_config['film_strip'] = isset($_POST['film_strip']); - $new_config['overlay_video_icon'] = isset($_POST['overlay_video_icon']); - $new_config['overlay_video_pos'] = in_array(($_POST['overlay_video_pos'] ?? ''), array('bottom-right', 'bottom-left')) - ? $_POST['overlay_video_pos'] - : 'bottom-right'; - $new_config['overlay_play'] = isset($_POST['overlay_play']); - $play_size = (int)($_POST['overlay_play_size'] ?? 20); - $new_config['overlay_play_size'] = max(5, min(50, $play_size)); - $play_opacity = (int)($_POST['overlay_play_opacity'] ?? 70); - $new_config['overlay_play_opacity'] = max(10, min(100, $play_opacity)); - - conf_update_param('companion_config', json_encode($new_config)); - $conf['companion_config'] = json_encode($new_config); - - $action_status = 'ok'; - $action_message = l10n('lrc_settings_saved'); -} - -// ========================================================================= -// Gather server information -// ========================================================================= - -// PHP -$disabled_functions = array_map('trim', explode(',', ini_get('disable_functions'))); -$exec_available = function_exists('exec') && !in_array('exec', $disabled_functions); - -$php = array( - 'version' => PHP_VERSION, - 'memory_limit' => ini_get('memory_limit'), - 'upload_max_filesize' => ini_get('upload_max_filesize'), - 'post_max_size' => ini_get('post_max_size'), - 'max_execution_time' => ini_get('max_execution_time'), - 'exec_available' => $exec_available, -); - -// Graphics -$gfx_gd = false; -$gfx_imagick = false; -if (function_exists('gd_info')) -{ - $gd = gd_info(); - $gfx_gd = isset($gd['GD Version']) ? $gd['GD Version'] : 'unknown'; -} -if (extension_loaded('imagick')) -{ - try { - $ver = Imagick::getVersion(); - $gfx_imagick = isset($ver['versionString']) ? $ver['versionString'] : 'unknown'; - } catch (Exception $e) { - $gfx_imagick = 'error: ' . $e->getMessage(); - } -} - -// CLI tools -if ($exec_available) -{ - $ffmpeg = companion_detect_tool('ffmpeg', '-version'); - $ffprobe = companion_detect_tool('ffprobe', '-version'); - $exiftool = companion_detect_tool('exiftool', '-ver'); - $mediainfo = companion_detect_tool('mediainfo', '--Version'); -} -else -{ - $notice = 'exec() is disabled — CLI tools cannot be detected'; - $ffmpeg = array('installed' => false, 'notice' => $notice); - $ffprobe = array('installed' => false, 'notice' => $notice); - $exiftool = array('installed' => false, 'notice' => $notice); - $mediainfo = array('installed' => false, 'notice' => $notice); -} - -// Piwigo config -// LRC_VIDEO_READY is derived from the config file on disk (not $conf in memory, -// which may be stale due to opcache after enable/disable actions). -$companion_block = companion_has_video_block(); -$video_ready = $companion_block; -// Read upload_all and file_ext from disk to avoid opcache stale values after enable/disable. -$upload_all = $companion_block; -$found_video_exts = $companion_block ? array('mp4', 'm4v', 'ogg', 'ogv', 'webm') : array(); -$config_writable = companion_is_local_config_writable(); - -// VideoJS detection -// $plugins global: keys are plugin IDs, values contain plugin metadata. -// Active plugins only appear in $plugins; installed-but-inactive ones require a DB query. -global $plugins; -$videojs_installed = false; -$videojs_active = false; -$videojs_name = ''; - -// Helper: test if a string contains "videojs" or "video_js" -function companion_is_videojs($str) -{ - $s = strtolower($str); - return strpos($s, 'videojs') !== false || strpos($s, 'video_js') !== false; -} - -// 1. Search active plugins ($plugins key = plugin id) -if (!empty($plugins)) -{ - foreach ($plugins as $pid => $pdata) - { - $name = isset($pdata['name']) ? $pdata['name'] : $pid; - if (companion_is_videojs($pid) || companion_is_videojs($name)) - { - $videojs_installed = true; - $videojs_active = true; // present in $plugins → active - $videojs_name = $name; - break; - } - } -} - -// 2. If not found among active plugins, check installed-but-inactive via DB. -// PLUGINS_TABLE only has columns: id, state, version — no name column. -if (!$videojs_installed) -{ - $query = ' -SELECT id, state -FROM ' . PLUGINS_TABLE . ' -;'; - $result_db = pwg_query($query); - while ($row = pwg_db_fetch_assoc($result_db)) - { - if (companion_is_videojs($row['id'])) - { - $videojs_installed = true; - $videojs_active = ($row['state'] === 'active'); - $videojs_name = $row['id']; // no name in DB, use id - break; - } - } -} - -// ========================================================================= -// Theme detection (clear / dark) — same logic as centralAdmin -// ========================================================================= -$lrc_theme = 'clear'; -if (function_exists('userprefs_get_param')) -{ - $lrc_theme = (userprefs_get_param('admin_theme', 'clear') === 'roma') ? 'dark' : 'clear'; -} - -// ========================================================================= -// Assign to template -// ========================================================================= -// Read plugin version from main file header -$lrc_plugin_version = '?'; -$main_file = dirname(__FILE__) . '/main.inc.php'; -if (file_exists($main_file)) -{ - $header = file_get_contents($main_file, false, null, 0, 512); - if (preg_match('/Version:\s*([^\r\n]+)/', $header, $m)) - $lrc_plugin_version = trim($m[1]); -} - -$template->assign(array( - 'LRC_ADMIN_URL' => get_admin_plugin_menu_link(dirname(__FILE__).'/admin.php'), - 'LRC_CSS_URL' => get_root_url() . 'plugins/' . basename(dirname(__FILE__)) . '/assets/admin.css', - 'PWG_TOKEN' => get_pwg_token(), - 'LRC_TAB' => $page['tab'], - 'LRC_PLUGIN_VERSION' => $lrc_plugin_version, - - // Action result - 'LRC_ACTION_STATUS' => $action_status, - 'LRC_ACTION_MESSAGE' => $action_message, - - // PHP - 'LRC_PHP_VERSION' => $php['version'], - 'LRC_PHP_MEM' => $php['memory_limit'], - 'LRC_PHP_UPLOAD' => $php['upload_max_filesize'], - 'LRC_PHP_POST' => $php['post_max_size'], - 'LRC_PHP_MAXTIME' => $php['max_execution_time'], - 'LRC_PHP_EXEC' => $exec_available, - 'LRC_PHP_EXEC_NOTE' => $exec_available ? '' : l10n('lrc_exec_disabled_note'), - - // Graphics - 'LRC_GD' => $gfx_gd, - 'LRC_IMAGICK' => $gfx_imagick, - - // CLI tools - 'LRC_FFMPEG_OK' => $ffmpeg['installed'], - 'LRC_FFMPEG_VER' => $ffmpeg['installed'] ? ($ffmpeg['version'] ?? 'Installed') : ($ffmpeg['notice'] ?? 'Not found'), - 'LRC_FFPROBE_OK' => $ffprobe['installed'], - 'LRC_FFPROBE_VER' => $ffprobe['installed'] ? ($ffprobe['version'] ?? 'Available') : ($ffprobe['notice'] ?? 'Not found'), - 'LRC_EXIFTOOL_OK' => $exiftool['installed'], - 'LRC_EXIFTOOL_VER' => $exiftool['installed'] ? ($exiftool['version'] ?? 'Installed') : ($exiftool['notice'] ?? 'Not found'), - 'LRC_MEDIAINFO_OK' => $mediainfo['installed'], - 'LRC_MEDIAINFO_VER' => $mediainfo['installed'] ? ($mediainfo['version'] ?? 'Installed') : ($mediainfo['notice'] ?? 'Not found'), - 'LRC_FFMPEG_NO_TPL' => (!$ffmpeg['installed'] && !isset($ffmpeg['notice'])), - - // Piwigo - 'LRC_PIWIGO_VER' => PHPWG_VERSION, - 'LRC_PUBLIC_THEME' => companion_get_public_theme(), - 'LRC_PARENT_THEME' => companion_get_parent_theme(), - 'LRC_UPLOAD_ALL' => $upload_all, - 'LRC_VIDEO_EXTS' => implode(', ', $found_video_exts), - 'LRC_VIDEO_READY' => $video_ready, - 'LRC_CFG_WRITABLE' => $config_writable, - - // VideoJS - 'LRC_VJS_INSTALLED' => $videojs_installed, - 'LRC_VJS_ACTIVE' => $videojs_active, - 'LRC_VJS_NAME' => $videojs_name, - - // Theme - 'LRC_THEME' => $lrc_theme, - - // OS - 'LRC_OS' => PHP_OS, - 'LRC_WEBSERVER' => $_SERVER['SERVER_SOFTWARE'] ?? 'unknown', - - // Settings - 'LRC_CFG' => companion_get_all_config(), - 'LRC_HAS_GD' => function_exists('imagecreatetruecolor'), - 'LRC_HAS_VIDEO_ICON' => file_exists(dirname(__FILE__) . '/assets/video-icon.png'), - 'LRC_COMPANION_BLOCK' => $companion_block, -)); - -// Render template -$template->set_filenames(array( - 'plugin_admin_content' => dirname(__FILE__) . '/admin.tpl', -)); -$template->assign_var_from_handle('ADMIN_CONTENT', 'plugin_admin_content'); diff --git a/lightroom_companion/admin.tpl b/lightroom_companion/admin.tpl deleted file mode 100644 index e2bb27f..0000000 --- a/lightroom_companion/admin.tpl +++ /dev/null @@ -1,424 +0,0 @@ - - - - -{* ---- Animation pellicule (activation vidéo) ---- *} - - -
- -

Lightroom Companion v{$LRC_PLUGIN_VERSION}

- - {* Tabsheet natif Piwigo *} - {include file='tabsheet.tpl'} - - {* ---- Action result ---- *} - {if $LRC_ACTION_STATUS eq 'ok' or $LRC_ACTION_STATUS eq 'already_configured'} -

{$LRC_ACTION_MESSAGE}

- {elseif $LRC_ACTION_STATUS} -

{$LRC_ACTION_MESSAGE}

- {/if} - - {* ================================================================= *} - {* TAB VIDEO *} - {* ================================================================= *} - {if $LRC_TAB eq 'video'} - - {* --- Statut global --- *} - {if $LRC_VIDEO_READY and $LRC_VJS_ACTIVE} -
-
-
- {'lrc_video_fully_active'|translate} - {'lrc_video_fully_active_sub'|translate} -
-
- {else} -
-
!
-
- {'lrc_video_not_configured'|translate} - {'lrc_video_not_configured_sub'|translate} -
-
- {/if} - - {* --- Upload Piwigo --- *} -
{'lrc_section_video_upload'|translate}
- - - - - - - - - - - - - -
{'lrc_upload_status'|translate} - {if $LRC_VIDEO_READY} - {'lrc_ready'|translate} - {else} - {'lrc_not_configured'|translate} - {/if} -
{'lrc_all_file_types'|translate} - {if $LRC_UPLOAD_ALL} - {'lrc_enabled'|translate} - {else} - {'lrc_disabled'|translate} - {/if} -
{'lrc_video_extensions'|translate} - {if $LRC_VIDEO_EXTS} - {$LRC_VIDEO_EXTS} - {else} - {'lrc_none_configured'|translate} - {/if} -
- - {if $LRC_CFG_WRITABLE} -
- {if not $LRC_VIDEO_READY} -
- - - -
-

{'lrc_enable_video_note'|translate}

- {elseif $LRC_COMPANION_BLOCK} -
- - - -
-

{'lrc_disable_video_note'|translate}

- {/if} -
- {elseif not $LRC_VIDEO_READY} -

{'lrc_config_not_writable'|translate}

-
$conf['upload_form_all_types'] = true; -$conf['file_ext'] = array_merge($conf['picture_ext'], array('mp4', 'm4v', 'ogg', 'ogv', 'webm'));
- {/if} - - {* --- VideoJS plugin --- *} -
{'lrc_section_videojs'|translate}
- - {if $LRC_VJS_INSTALLED} - - - - - - - - - {else} - - - - - {/if} -
{'lrc_plugin'|translate}{$LRC_VJS_NAME}
{'lrc_status'|translate} - {if $LRC_VJS_ACTIVE} - {'lrc_active'|translate} - {else} - {'lrc_installed_inactive'|translate} - {/if} -
VideoJS{'lrc_not_installed'|translate}
- {if not $LRC_VJS_INSTALLED} -

{'lrc_videojs_install_note'|translate}

- {elseif not $LRC_VJS_ACTIVE} -

{'lrc_videojs_activate_note'|translate}

- {/if} - - {/if}{* end tab video *} - - {* ================================================================= *} - {* TAB SERVER *} - {* ================================================================= *} - {if $LRC_TAB eq 'server'} - - {* --- CLI Tools --- *} -
{'lrc_section_media_tools'|translate}
- - - - - - - - - - - - - - - - - -
FFmpeg{$LRC_FFMPEG_VER}
FFprobe{$LRC_FFPROBE_VER}
ExifTool{$LRC_EXIFTOOL_VER}
MediaInfo{$LRC_MEDIAINFO_VER}
- {if $LRC_FFMPEG_NO_TPL} -

{'lrc_ffmpeg_no_note'|translate}

- {/if} - - {* --- Server & PHP --- *} -
{'lrc_section_server_php'|translate}
- - - - - - - - - - - - -
{'lrc_os'|translate}{$LRC_OS}
{'lrc_web_server'|translate}{$LRC_WEBSERVER}
{'lrc_php_version'|translate}{$LRC_PHP_VERSION}
upload_max_filesize{$LRC_PHP_UPLOAD}
post_max_size{$LRC_PHP_POST}
memory_limit{$LRC_PHP_MEM}
max_execution_time{$LRC_PHP_MAXTIME}s
{'lrc_exec_available'|translate} - {if $LRC_PHP_EXEC} - {'lrc_yes'|translate} - {else} - {'lrc_no'|translate} - {/if} -
- {if $LRC_PHP_EXEC_NOTE} -

{$LRC_PHP_EXEC_NOTE}

- {/if} - - {* --- Graphics --- *} -
{'lrc_section_graphics'|translate}
- - - - - - - - - -
GD - {if $LRC_GD} - {$LRC_GD} - {else} - {'lrc_not_available'|translate} - {/if} -
ImageMagick - {if $LRC_IMAGICK} - {$LRC_IMAGICK} - {else} - {'lrc_not_available'|translate} - {/if} -
- - {* --- Piwigo --- *} -
{'lrc_section_piwigo'|translate}
- - - - - - - - - - -
{'lrc_version'|translate}{$LRC_PIWIGO_VER}
{'lrc_guest_theme'|translate} - {$LRC_PUBLIC_THEME} - {if $LRC_PARENT_THEME neq $LRC_PUBLIC_THEME} - ↳ {'lrc_parent'|translate}: {$LRC_PARENT_THEME} - {/if} -
{'lrc_config_writable'|translate} - {if $LRC_CFG_WRITABLE} - {'lrc_yes'|translate} - {else} - {'lrc_no'|translate} - {/if} -
- - {/if}{* end tab server *} - - {* ================================================================= *} - {* TAB SETTINGS *} - {* ================================================================= *} - {if $LRC_TAB eq 'settings'} - - {if not $LRC_HAS_GD} -
-
!
-
- {'lrc_gd_not_available'|translate} - {'lrc_gd_not_available_sub'|translate} -
-
- {/if} - -
- - - - {* --- Thumbnail size --- *} -
{'lrc_section_thumbnail'|translate}
- - - - - - - - - -
{'lrc_max_size'|translate} - px - ({'lrc_longest_side'|translate}) -
{'lrc_no_upscale'|translate} - -
- - {* --- Film strip --- *} -
{'lrc_section_filmstrip'|translate}
- - - - - -
{'lrc_filmstrip_label'|translate} - -
-

{'lrc_filmstrip_note'|translate}

- - {* --- Overlays --- *} -
{'lrc_section_overlays'|translate}
- - - - - - - - - - - - - - - - - - - - - -
{'lrc_video_icon'|translate} - - {if not $LRC_HAS_VIDEO_ICON} - - ({'lrc_missing_asset'|translate}: assets/video-icon.png) - - {/if} -
{'lrc_icon_position'|translate} - -    - -
{'lrc_play_button'|translate} - - {'lrc_play_native_note'|translate} -
{'lrc_play_size'|translate} - % - {'lrc_play_size_note'|translate} -
{'lrc_play_opacity'|translate} - % - {'lrc_play_opacity_note'|translate} -
-

{'lrc_overlay_asset_note'|translate}

- -
- -
-
- - {/if}{* end tab settings *} - -
- - diff --git a/lightroom_companion/assets/README.txt b/lightroom_companion/assets/README.txt deleted file mode 100644 index f132a62..0000000 --- a/lightroom_companion/assets/README.txt +++ /dev/null @@ -1,13 +0,0 @@ -Lightroom Companion — Overlay Assets -===================================== - -Place your custom PNG files here (with transparency): - - video-icon.png Shown in a corner of the thumbnail (configurable position). - Recommended size: 128x128 px or larger (will be scaled to ~20% of thumbnail). - - play-button.png Shown centered on the thumbnail. - Recommended size: 256x256 px or larger (will be scaled to ~30% of thumbnail). - -Both files are optional. If missing, the corresponding overlay is disabled in Settings. -Configure overlays in: Piwigo Admin > Plugins > Lightroom Companion > Settings tab. diff --git a/lightroom_companion/assets/admin.css b/lightroom_companion/assets/admin.css deleted file mode 100644 index fc6f4c8..0000000 --- a/lightroom_companion/assets/admin.css +++ /dev/null @@ -1,125 +0,0 @@ -/* === Lightroom Companion — variables thème === */ - -/* ---- Animation pellicule ---- */ -@keyframes lrc-film-scroll { - from { transform: translateX(0); } - to { transform: translateX(-50%); } -} -#lrc-film-overlay { - flex-direction: column; - align-items: center; - gap: 14px; - padding: 20px 0 14px; - margin-bottom: 8px; - overflow: hidden; -} -.lrc-film-strip { - width: 100%; - overflow: hidden; - background: #1a1a1a; - border-radius: 4px; - padding: 6px 0; -} -.lrc-film-track { - display: flex; - width: max-content; - animation: lrc-film-scroll 1.4s linear infinite; -} -.lrc-film-frame { - display: flex; - flex-direction: column; - justify-content: space-between; - width: 52px; - height: 36px; - background: #2a2a2a; - border: 1px solid #444; - border-radius: 2px; - margin: 0 3px; - padding: 3px 0; - align-items: center; - flex-shrink: 0; -} -.lrc-film-hole { - display: block; - width: 10px; - height: 7px; - background: #1a1a1a; - border-radius: 2px; -} -.lrc-film-label { - margin: 0; - font-style: italic; - color: var(--lrc-color-note); - font-size: 0.9em; -} - -:root, -[data-lrc-theme="clear"] { - --lrc-color-section-border : #ccc; - --lrc-color-section-text : #444; - --lrc-color-label : #555; - --lrc-color-note : #666; - --lrc-color-pre-bg : #f5f5f5; - --lrc-color-pre-border : #ddd; - --lrc-status-bg-ok : #edfaed; - --lrc-status-bg-err : #fdecea; - --lrc-status-border-ok : #2a9d2a; - --lrc-status-border-err : #c0392b; -} -[data-lrc-theme="dark"] { - --lrc-color-section-border : #555; - --lrc-color-section-text : #ccc; - --lrc-color-label : #aaa; - --lrc-color-note : #888; - --lrc-color-pre-bg : #1e1e1e; - --lrc-color-pre-border : #444; - --lrc-status-bg-ok : #1a2e1a; - --lrc-status-bg-err : #2e1a1a; - --lrc-status-border-ok : #2a9d2a; - --lrc-status-border-err : #c0392b; -} - -.lrc-wrap { max-width: 820px; } -.lrc-section { margin: 20px 0 6px; font-size: 1.05em; font-weight: bold; - border-bottom: 2px solid var(--lrc-color-section-border); - padding-bottom: 3px; color: var(--lrc-color-section-text); } -.lrc-table { border-collapse: collapse; width: 100%; margin-bottom: 4px; } -.lrc-table td { padding: 4px 6px; vertical-align: top; } -.lrc-label { width: 230px; font-weight: bold; color: var(--lrc-color-label); white-space: nowrap; } -.lrc-ok { color: #2a9d2a; font-weight: bold; } -.lrc-err { color: #c0392b; font-weight: bold; } -.lrc-warn { color: #e67e22; font-weight: bold; } -.lrc-note { font-size: 0.87em; color: var(--lrc-color-note); font-style: italic; - padding: 2px 6px 6px 240px; } -.lrc-action { margin: 14px 0 4px; } -.lrc-pre { background: var(--lrc-color-pre-bg); border: 1px solid var(--lrc-color-pre-border); - padding: 8px 12px; font-family: monospace; font-size: 0.88em; white-space: pre-wrap; } - -/* Status banner (onglet Video) */ -.lrc-status-banner { - display: flex; align-items: center; gap: 16px; - padding: 14px 18px; border-radius: 4px; margin: 16px 0; - border-left: 4px solid var(--lrc-status-border-ok); - background: var(--lrc-status-bg-ok); -} -.lrc-status-banner.lrc-banner-err { - border-color: var(--lrc-status-border-err); - background: var(--lrc-status-bg-err); -} -.lrc-status-icon { font-size: 2em; line-height: 1; } -.lrc-status-text { flex: 1; } -.lrc-status-text strong { display: block; font-size: 1.1em; margin-bottom: 2px; } -.lrc-status-text small { color: var(--lrc-color-note); } - -/* Divers éléments extraits des styles inline */ -.lrc-version-small { font-size: 0.55em; font-style: italic; font-weight: normal; } -.lrc-action-message { margin: 10px 0; } -.lrc-btn-danger { background: #c0392b; } -.lrc-err-config { margin-top: 10px; } -.lrc-parent-theme { margin-left: 8px; } -.lrc-input-hint { color: var(--lrc-color-note); font-size: 0.87em; margin-left: 8px; } -.lrc-input-sm { width: 80px; } -.lrc-input-xs { width: 60px; } -.lrc-warn-asset { font-size: 0.87em; margin-left: 8px; } -.lrc-note-inline { margin-left: 6px; } -.lrc-note-inline-lg { margin-left: 8px; font-size: 0.87em; } diff --git a/lightroom_companion/language/en_UK/index.php b/lightroom_companion/language/en_UK/index.php deleted file mode 100644 index 060a204..0000000 --- a/lightroom_companion/language/en_UK/index.php +++ /dev/null @@ -1,4 +0,0 @@ -upload_form_all_types = true and video extensions (mp4, m4v, ogg, ogv, webm) to local/config/config.inc.php.'; -$lang['lrc_disable_video'] = 'Disable Video Support'; -$lang['lrc_disable_video_note'] = 'Removes the Companion block from local/config/config.inc.php. Video uploads will no longer be allowed.'; -$lang['lrc_config_not_writable'] = 'Config file is not writable. Add manually to local/config/config.inc.php:'; - -// == VIDEOJS == -$lang['lrc_section_videojs'] = 'VideoJS Plugin'; -$lang['lrc_plugin'] = 'Plugin'; -$lang['lrc_status'] = 'Status'; -$lang['lrc_active'] = 'Active'; -$lang['lrc_installed_inactive'] = 'Installed but INACTIVE'; -$lang['lrc_not_installed'] = 'Not installed'; -$lang['lrc_videojs_install_note'] = 'Install and activate the VideoJS plugin from Piwigo administration for in-gallery video playback.'; -$lang['lrc_videojs_activate_note'] = 'Activate VideoJS in Piwigo administration (Plugins menu) for video playback to work.'; - -// == TAB SERVER == -$lang['lrc_section_media_tools'] = 'Video & Media Tools'; -$lang['lrc_ffmpeg_no_note'] = 'Without FFmpeg, videos will upload but Piwigo will not generate a custom thumbnail for them.'; -$lang['lrc_section_server_php'] = 'Server & PHP'; -$lang['lrc_os'] = 'OS'; -$lang['lrc_web_server'] = 'Web Server'; -$lang['lrc_php_version'] = 'PHP Version'; -$lang['lrc_exec_available'] = 'exec() available'; -$lang['lrc_yes'] = 'Yes'; -$lang['lrc_no'] = 'No'; -$lang['lrc_exec_disabled_note'] = 'exec() is disabled — contact your hosting provider'; -$lang['lrc_section_graphics'] = 'Graphics Libraries'; -$lang['lrc_not_available'] = 'Not available'; -$lang['lrc_section_piwigo'] = 'Piwigo Gallery'; -$lang['lrc_version'] = 'Version'; -$lang['lrc_guest_theme'] = 'Guest theme'; -$lang['lrc_parent'] = 'parent'; -$lang['lrc_config_writable'] = 'Config file writable'; - -// == TAB SETTINGS == -$lang['lrc_gd_not_available'] = 'GD library not available'; -$lang['lrc_gd_not_available_sub'] = 'Thumbnail processing requires the PHP GD extension. Posters will be stored as-is.'; -$lang['lrc_section_thumbnail'] = 'Video Thumbnail'; -$lang['lrc_max_size'] = 'Max size (px)'; -$lang['lrc_longest_side'] = 'longest side'; -$lang['lrc_no_upscale'] = 'No upscale'; -$lang['lrc_no_enlarge'] = "Don't enlarge small images"; -$lang['lrc_section_filmstrip'] = 'Film Strip Effect'; -$lang['lrc_filmstrip_label'] = '35mm film border'; -$lang['lrc_filmstrip_option'] = 'Add perforated film borders (square output)'; -$lang['lrc_filmstrip_note'] = 'The thumbnail becomes square with black letterbox and 35mm-style sprocket holes on the sides.'; -$lang['lrc_section_overlays'] = 'Overlays'; -$lang['lrc_video_icon'] = 'Video icon (corner)'; -$lang['lrc_video_icon_option'] = 'Show video file icon'; -$lang['lrc_missing_asset'] = 'missing'; -$lang['lrc_icon_position'] = 'Icon position'; -$lang['lrc_bottom_right'] = 'Bottom-right'; -$lang['lrc_bottom_left'] = 'Bottom-left'; -$lang['lrc_play_button'] = 'Play button (center)'; -$lang['lrc_play_button_option'] = 'Show play button overlay'; -$lang['lrc_play_native_note'] = 'drawn natively, no PNG needed'; -$lang['lrc_play_size'] = 'Play button size'; -$lang['lrc_play_size_note'] = 'of the shortest side (5–50%)'; -$lang['lrc_play_opacity'] = 'Play button opacity'; -$lang['lrc_play_opacity_note'] = 'transparency of the overlay (10–100%)'; -$lang['lrc_overlay_asset_note'] = 'Place your custom PNG file (with transparency) in the lightroom_companion/assets/ folder for the video icon overlay.'; -$lang['lrc_save_settings'] = 'Save Settings'; -$lang['lrc_settings_saved'] = 'Settings saved.'; - -// == VIDEO META (picture page) == -$lang['lrc_video_original'] = 'Video (original)'; -$lang['lrc_video_converted'] = 'Video (converted)'; diff --git a/lightroom_companion/language/fr_FR/index.php b/lightroom_companion/language/fr_FR/index.php deleted file mode 100644 index 060a204..0000000 --- a/lightroom_companion/language/fr_FR/index.php +++ /dev/null @@ -1,4 +0,0 @@ -upload_form_all_types = true et les extensions vidéo (mp4, m4v, ogg, ogv, webm) dans local/config/config.inc.php.'; -$lang['lrc_disable_video'] = 'Désactiver le support vidéo'; -$lang['lrc_disable_video_note'] = 'Supprime le bloc Companion de local/config/config.inc.php. L\'upload de vidéos ne sera plus autorisé.'; -$lang['lrc_config_not_writable'] = 'Le fichier de configuration n\'est pas modifiable. Ajoutez manuellement dans local/config/config.inc.php :'; - -// == VIDEOJS == -$lang['lrc_section_videojs'] = 'Plugin VideoJS'; -$lang['lrc_plugin'] = 'Plugin'; -$lang['lrc_status'] = 'Statut'; -$lang['lrc_active'] = 'Actif'; -$lang['lrc_installed_inactive'] = 'Installé mais INACTIF'; -$lang['lrc_not_installed'] = 'Non installé'; -$lang['lrc_videojs_install_note'] = 'Installez et activez le plugin VideoJS depuis l\'administration Piwigo pour la lecture vidéo dans la galerie.'; -$lang['lrc_videojs_activate_note'] = 'Activez VideoJS dans l\'administration Piwigo (menu Plugins) pour que la lecture vidéo fonctionne.'; - -// == ONGLET SERVEUR == -$lang['lrc_section_media_tools'] = 'Outils vidéo & média'; -$lang['lrc_ffmpeg_no_note'] = 'Sans FFmpeg, les vidéos seront uploadées mais Piwigo ne générera pas de vignette personnalisée.'; -$lang['lrc_section_server_php'] = 'Serveur & PHP'; -$lang['lrc_os'] = 'OS'; -$lang['lrc_web_server'] = 'Serveur web'; -$lang['lrc_php_version'] = 'Version PHP'; -$lang['lrc_exec_available'] = 'exec() disponible'; -$lang['lrc_yes'] = 'Oui'; -$lang['lrc_no'] = 'Non'; -$lang['lrc_exec_disabled_note'] = 'exec() est désactivé — contactez votre hébergeur'; -$lang['lrc_section_graphics'] = 'Bibliothèques graphiques'; -$lang['lrc_not_available'] = 'Non disponible'; -$lang['lrc_section_piwigo'] = 'Galerie Piwigo'; -$lang['lrc_version'] = 'Version'; -$lang['lrc_guest_theme'] = 'Thème visiteur'; -$lang['lrc_parent'] = 'parent'; -$lang['lrc_config_writable'] = 'Fichier config modifiable'; - -// == ONGLET RÉGLAGES == -$lang['lrc_gd_not_available'] = 'Bibliothèque GD non disponible'; -$lang['lrc_gd_not_available_sub'] = 'Le traitement des vignettes nécessite l\'extension PHP GD. Les posters seront stockés tels quels.'; -$lang['lrc_section_thumbnail'] = 'Vignette vidéo'; -$lang['lrc_max_size'] = 'Taille max (px)'; -$lang['lrc_longest_side'] = 'côté le plus long'; -$lang['lrc_no_upscale'] = 'Pas d\'agrandissement'; -$lang['lrc_no_enlarge'] = 'Ne pas agrandir les petites images'; -$lang['lrc_section_filmstrip'] = 'Effet pellicule'; -$lang['lrc_filmstrip_label'] = 'Bordure pellicule 35mm'; -$lang['lrc_filmstrip_option'] = 'Ajouter des bordures perforées (sortie carrée)'; -$lang['lrc_filmstrip_note'] = 'La vignette devient carrée avec un letterbox noir et des perforations style 35mm sur les côtés.'; -$lang['lrc_section_overlays'] = 'Superpositions'; -$lang['lrc_video_icon'] = 'Icône vidéo (coin)'; -$lang['lrc_video_icon_option'] = 'Afficher l\'icône fichier vidéo'; -$lang['lrc_missing_asset'] = 'manquant'; -$lang['lrc_icon_position'] = 'Position de l\'icône'; -$lang['lrc_bottom_right'] = 'Bas-droite'; -$lang['lrc_bottom_left'] = 'Bas-gauche'; -$lang['lrc_play_button'] = 'Bouton lecture (centre)'; -$lang['lrc_play_button_option'] = 'Afficher le bouton lecture'; -$lang['lrc_play_native_note'] = 'dessiné nativement, pas de PNG nécessaire'; -$lang['lrc_play_size'] = 'Taille bouton lecture'; -$lang['lrc_play_size_note'] = 'du côté le plus court (5–50%)'; -$lang['lrc_play_opacity'] = 'Opacité bouton lecture'; -$lang['lrc_play_opacity_note'] = 'transparence de la superposition (10–100%)'; -$lang['lrc_overlay_asset_note'] = 'Placez votre fichier PNG personnalisé (avec transparence) dans le dossier lightroom_companion/assets/ pour la superposition d\'icône vidéo.'; -$lang['lrc_save_settings'] = 'Enregistrer les réglages'; -$lang['lrc_settings_saved'] = 'Réglages enregistrés.'; - -// == META VIDÉO (page photo) == -$lang['lrc_video_original'] = 'Vidéo (originale)'; -$lang['lrc_video_converted'] = 'Vidéo (convertie)'; diff --git a/lightroom_companion/language/index.php b/lightroom_companion/language/index.php deleted file mode 100644 index c3299b6..0000000 --- a/lightroom_companion/language/index.php +++ /dev/null @@ -1,4 +0,0 @@ - 'Lightroom Companion', - 'URL' => get_admin_plugin_menu_link(__DIR__ . '/admin.php'), - )); - return $menu; -} - -function companion_add_methods($arr) -{ - $service = &$arr[0]; - - $service->addMethod( - 'pwg.companion.getConfig', - 'companion_get_config', - array(), - 'Returns server configuration: PHP, upload limits, graphics libs, FFmpeg, video readiness.', - null, - array('admin_only' => true) - ); - - $service->addMethod( - 'pwg.companion.enableVideoSupport', - 'companion_enable_video_support', - array(), - 'Enables video upload support by writing upload_form_all_types and file_ext to local config.', - null, - array('admin_only' => true) - ); - - $service->addMethod( - 'pwg.companion.disableVideoSupport', - 'companion_disable_video_support', - array(), - 'Removes the Companion video block from local/config/config.inc.php.', - null, - array('admin_only' => true) - ); - - $service->addMethod( - 'pwg.companion.setRepresentative', - 'companion_set_representative', - array( - 'image_id' => array( - 'default' => null, - 'type' => WS_TYPE_INT, - 'info' => 'Piwigo image/video ID', - ), - ), - 'Upload a poster/thumbnail image as the representative for a video.', - null, - array('admin_only' => true) - ); - - $service->addMethod( - 'pwg.companion.setVideoInfo', - 'companion_set_video_info', - array( - 'image_id' => array( - 'default' => null, - 'type' => WS_TYPE_INT, - 'info' => 'Piwigo image/video ID', - ), - 'width' => array( - 'default' => null, - 'type' => WS_TYPE_INT, - 'info' => 'Video width in pixels', - ), - 'height' => array( - 'default' => null, - 'type' => WS_TYPE_INT, - 'info' => 'Video height in pixels', - ), - 'filesize' => array( - 'default' => null, - 'type' => WS_TYPE_INT, - 'info' => 'Video file size in bytes (optional)', - ), - ), - 'Sets video dimensions and optional filesize in the Piwigo images table.', - null, - array('admin_only' => true) - ); - - $service->addMethod( - 'pwg.companion.setVideoMeta', - 'companion_set_video_meta', - array( - 'image_id' => array('default' => null, 'type' => WS_TYPE_INT), - 'orig_width' => array('default' => null, 'type' => WS_TYPE_INT), - 'orig_height' => array('default' => null, 'type' => WS_TYPE_INT), - 'orig_fps' => array('default' => null), - 'orig_bitrate' => array('default' => null, 'type' => WS_TYPE_INT), - 'orig_codec' => array('default' => null), - 'orig_format' => array('default' => null), - 'orig_filesize'=> array('default' => null, 'type' => WS_TYPE_INT), - 'conv_width' => array('default' => null, 'type' => WS_TYPE_INT), - 'conv_height' => array('default' => null, 'type' => WS_TYPE_INT), - 'conv_fps' => array('default' => null), - 'conv_bitrate' => array('default' => null, 'type' => WS_TYPE_INT), - 'conv_codec' => array('default' => null), - 'conv_format' => array('default' => null), - 'conv_filesize'=> array('default' => null, 'type' => WS_TYPE_INT), - ), - 'Store extended video metadata (source + VTK variant) for a Piwigo image.', - null, - array('admin_only' => true) - ); -} - -// ========================================================================= -// pwg.companion.getConfig -// ========================================================================= -function companion_get_config($params, &$service) -{ - $result = array(); - - // ----- PHP ----- - $disabled_functions = array_map('trim', explode(',', ini_get('disable_functions'))); - $exec_available = function_exists('exec') && !in_array('exec', $disabled_functions); - - $result['php'] = array( - 'version' => PHP_VERSION, - 'memory_limit' => ini_get('memory_limit'), - 'upload_max_filesize' => ini_get('upload_max_filesize'), - 'post_max_size' => ini_get('post_max_size'), - 'max_execution_time' => ini_get('max_execution_time'), - 'max_input_time' => ini_get('max_input_time'), - 'max_file_uploads' => ini_get('max_file_uploads'), - 'exec_available' => $exec_available, - 'disabled_functions' => $exec_available ? '' : ini_get('disable_functions'), - ); - - // ----- Graphics library ----- - $gfx = array('gd' => false, 'imagick' => false); - - if (function_exists('gd_info')) - { - $gd = gd_info(); - $gfx['gd'] = array( - 'version' => isset($gd['GD Version']) ? $gd['GD Version'] : 'unknown', - 'jpeg' => !empty($gd['JPEG Support']), - 'png' => !empty($gd['PNG Support']), - 'webp' => !empty($gd['WebP Support']), - ); - } - - if (extension_loaded('imagick')) - { - try { - $im = new Imagick(); - $ver = Imagick::getVersion(); - $gfx['imagick'] = array( - 'version' => isset($ver['versionString']) ? $ver['versionString'] : 'unknown', - ); - } catch (Exception $e) { - $gfx['imagick'] = array('version' => 'error: ' . $e->getMessage()); - } - } - - $result['graphics'] = $gfx; - - // ----- CLI tools (FFmpeg, ExifTool, MediaInfo) ----- - if ($exec_available) - { - $result['ffmpeg'] = companion_detect_tool('ffmpeg', '-version'); - $result['ffprobe'] = companion_detect_tool('ffprobe', '-version'); - $result['exiftool'] = companion_detect_tool('exiftool', '-ver'); - $result['mediainfo'] = companion_detect_tool('mediainfo', '--Version'); - } - else - { - $notice = 'exec() is disabled by PHP configuration'; - $result['ffmpeg'] = array('installed' => false, 'notice' => $notice); - $result['ffprobe'] = array('installed' => false, 'notice' => $notice); - $result['exiftool'] = array('installed' => false, 'notice' => $notice); - $result['mediainfo'] = array('installed' => false, 'notice' => $notice); - } - - // ----- Piwigo config (video-relevant) ----- - global $conf; - - $upload_all = isset($conf['upload_form_all_types']) ? (bool)$conf['upload_form_all_types'] : false; - $file_ext = isset($conf['file_ext']) ? $conf['file_ext'] : array(); - $pic_ext = isset($conf['picture_ext']) ? $conf['picture_ext'] : array(); - - // Check for video extensions - $video_exts = array('mp4', 'm4v', 'ogg', 'ogv', 'webm', 'webmv', 'mpg', 'mpeg', 'mov', 'avi'); - $found_video_exts = array_values(array_intersect($file_ext, $video_exts)); - - $result['piwigo'] = array( - 'version' => PHPWG_VERSION, - 'upload_form_all_types' => $upload_all, - 'file_ext' => $file_ext, - 'picture_ext' => $pic_ext, - 'video_ext_configured' => $found_video_exts, - 'video_ready' => $upload_all && !empty($found_video_exts), - 'local_config_writable' => companion_is_local_config_writable(), - ); - - // ----- OS ----- - $result['server'] = array( - 'os' => PHP_OS, - 'software' => isset($_SERVER['SERVER_SOFTWARE']) ? $_SERVER['SERVER_SOFTWARE'] : 'unknown', - ); - - return $result; -} - -// ========================================================================= -// pwg.companion.enableVideoSupport -// ========================================================================= -function companion_enable_video_support($params, &$service) -{ - global $conf; - - $config_path = PHPWG_ROOT_PATH . 'local/config/config.inc.php'; - - // Check if already configured - $upload_all = isset($conf['upload_form_all_types']) ? (bool)$conf['upload_form_all_types'] : false; - $file_ext = isset($conf['file_ext']) ? $conf['file_ext'] : array(); - $video_exts = array('mp4', 'm4v', 'ogg', 'ogv', 'webm'); - $found = array_intersect($file_ext, $video_exts); - - if ($upload_all && count($found) >= count($video_exts)) - { - return array( - 'status' => 'already_configured', - 'message' => 'Video support is already enabled.', - ); - } - - // Check writable - if (!companion_is_local_config_writable()) - { - return array( - 'status' => 'error', - 'message' => 'Cannot write to ' . $config_path . '. Check file permissions.', - ); - } - - // Read current file content - $content = ''; - if (file_exists($config_path)) - { - $content = file_get_contents($config_path); - } - - // Build lines to append - $lines_to_add = array(); - $lines_to_add[] = ''; - $lines_to_add[] = '// --- PiwigoPublish Companion: video upload support ---'; - - if (!$upload_all) - { - $lines_to_add[] = "\$conf['upload_form_all_types'] = true;"; - } - - // Always write file_ext with merge to ensure video extensions are present - $lines_to_add[] = "\$conf['file_ext'] = array_merge("; - $lines_to_add[] = " \$conf['picture_ext'],"; - $lines_to_add[] = " array('mp4', 'm4v', 'ogg', 'ogv', 'webm')"; - $lines_to_add[] = ");"; - - // Check if file has PHP opening tag - $php_open_tag = '<' . '?php'; - $php_close_tag = '?' . '>'; - if (empty($content) || strpos($content, $php_open_tag) === false) - { - $content = $php_open_tag . "\n" . implode("\n", $lines_to_add) . "\n"; - } - else - { - /* Remove trailing close-tag if present (we'll leave the file open) */ - $content = rtrim($content); - if (substr($content, -2) === $php_close_tag) - { - $content = rtrim(substr($content, 0, -2)); - } - $content .= "\n" . implode("\n", $lines_to_add) . "\n"; - } - - // Write - $written = @file_put_contents($config_path, $content); - if ($written === false) - { - return array( - 'status' => 'error', - 'message' => 'Failed to write to ' . $config_path, - ); - } - - if (function_exists('opcache_invalidate')) - opcache_invalidate($config_path, true); - - return array( - 'status' => 'ok', - 'message' => 'Video support has been enabled. Video extensions (mp4, m4v, ogg, ogv, webm) are now allowed.', - ); -} - -// ========================================================================= -// pwg.companion.disableVideoSupport -// ========================================================================= -function companion_disable_video_support($params, &$service) -{ - $config_path = PHPWG_ROOT_PATH . 'local/config/config.inc.php'; - $marker = '// --- PiwigoPublish Companion: video upload support ---'; - - if (!file_exists($config_path)) - { - return array('status' => 'error', 'message' => 'Config file not found.'); - } - - if (!is_writable($config_path)) - { - return array('status' => 'error', 'message' => 'Config file is not writable.'); - } - - $content = file_get_contents($config_path); - - if (strpos($content, $marker) === false) - { - return array('status' => 'already_configured', 'message' => 'Companion block not found — nothing to remove.'); - } - - // Split into lines, remove every line that belongs to the Companion block - // (the marker line + all non-empty lines following it). - $lines = explode("\n", $content); - $out = array(); - $skip = false; - foreach ($lines as $line) - { - if (strpos($line, $marker) !== false) - { - $skip = true; // start skipping from the marker line - continue; - } - if ($skip) - { - if (trim($line) === '') { $skip = false; } // blank line ends the block - continue; - } - $out[] = $line; - } - $content = rtrim(implode("\n", $out)) . "\n"; - - $written = @file_put_contents($config_path, $content); - if ($written === false) - { - return array('status' => 'error', 'message' => 'Failed to write to ' . $config_path); - } - - if (function_exists('opcache_invalidate')) - opcache_invalidate($config_path, true); - - return array('status' => 'ok', 'message' => 'Video support has been disabled. The Companion block has been removed from local/config/config.inc.php.'); -} - -/** - * Check if the Companion video block is present in local config. - */ -function companion_has_video_block() -{ - $config_path = PHPWG_ROOT_PATH . 'local/config/config.inc.php'; - if (!file_exists($config_path)) return false; - return strpos(file_get_contents($config_path), '// --- PiwigoPublish Companion: video upload support ---') !== false; -} - -// ========================================================================= -// pwg.companion.setRepresentative -// ========================================================================= -function companion_set_representative($params, &$service) -{ - global $conf; - - $image_id = (int)$params['image_id']; - if ($image_id <= 0) - { - return new PwgError(WS_ERR_INVALID_PARAM, 'image_id must be a positive integer'); - } - - // Verify image exists - $query = 'SELECT id, path FROM ' . IMAGES_TABLE . ' WHERE id = ' . $image_id . ';'; - $result = pwg_query($query); - $row = pwg_db_fetch_assoc($result); - if (!$row) - { - return new PwgError(404, 'Image ' . $image_id . ' not found'); - } - - // Expect an uploaded file named 'file' - if (empty($_FILES['file']) || $_FILES['file']['error'] !== UPLOAD_ERR_OK) - { - $err = isset($_FILES['file']['error']) ? $_FILES['file']['error'] : 'no file'; - return new PwgError(WS_ERR_INVALID_PARAM, 'No valid file uploaded (error: ' . $err . ')'); - } - - // Determine storage directory from existing image path - // path is relative to PHPWG_ROOT_PATH, e.g. "upload/2024/01/01/2024010...jpg" - $image_dir = PHPWG_ROOT_PATH . dirname($row['path']); - if (!is_dir($image_dir)) - { - return new PwgError(500, 'Image directory not found: ' . $image_dir); - } - - // Build representative filename: same basename, extension = uploaded file extension - $uploaded_ext = strtolower(pathinfo($_FILES['file']['name'], PATHINFO_EXTENSION)); - if (!in_array($uploaded_ext, array('jpg', 'jpeg', 'png', 'webp'))) - { - return new PwgError(WS_ERR_INVALID_PARAM, 'Poster must be jpg, jpeg, png or webp'); - } - - // Piwigo representative: stored in pwg_representative/ subdirectory - $image_basename = pathinfo($row['path'], PATHINFO_FILENAME); - $representative_filename = $image_basename . '.' . $uploaded_ext; - $representative_dir = $image_dir . '/pwg_representative'; - if (!is_dir($representative_dir)) - { - @mkdir($representative_dir, 0755, true); - } - $representative_path = $representative_dir . '/' . $representative_filename; - - if (!move_uploaded_file($_FILES['file']['tmp_name'], $representative_path)) - { - return new PwgError(500, 'Failed to move uploaded poster to ' . $representative_path); - } - - // Process thumbnail: resize + film strip + overlays (if GD available) - companion_process_representative($representative_path); - - // Invalidate Piwigo derivative cache for this image - $query = 'UPDATE ' . IMAGES_TABLE - . " SET representative_ext = '" . pwg_db_real_escape_string($uploaded_ext) . "'" - . ' WHERE id = ' . $image_id . ';'; - pwg_query($query); - - // Delete cached derivatives so Piwigo regenerates thumbnails - $image_path = PHPWG_ROOT_PATH . $row['path']; - if (function_exists('delete_element_derivatives')) - { - $element_info = array('id' => $image_id, 'path' => $row['path']); - delete_element_derivatives($element_info); - } - - return array( - 'status' => 'ok', - 'image_id' => $image_id, - 'representative_ext' => $uploaded_ext, - 'representative_path' => $representative_filename, - ); -} - -// ========================================================================= -// pwg.companion.setVideoInfo -// ========================================================================= -function companion_set_video_info($params, &$service) -{ - $image_id = (int)$params['image_id']; - if ($image_id <= 0) - { - return new PwgError(WS_ERR_INVALID_PARAM, 'image_id must be a positive integer'); - } - - // Verify image exists - $query = 'SELECT id FROM ' . IMAGES_TABLE . ' WHERE id = ' . $image_id . ';'; - $result = pwg_query($query); - $row = pwg_db_fetch_assoc($result); - if (!$row) - { - return new PwgError(404, 'Image ' . $image_id . ' not found'); - } - - // Build SET clause from provided parameters - $updates = array(); - - if (isset($params['width']) && $params['width'] !== null) - { - $width = (int)$params['width']; - if ($width > 0) $updates[] = 'width = ' . $width; - } - - if (isset($params['height']) && $params['height'] !== null) - { - $height = (int)$params['height']; - if ($height > 0) $updates[] = 'height = ' . $height; - } - - if (isset($params['filesize']) && $params['filesize'] !== null) - { - // Piwigo stores filesize in KB in the images table - $filesize_bytes = (int)$params['filesize']; - if ($filesize_bytes > 0) - { - $filesize_kb = (int)ceil($filesize_bytes / 1024); - $updates[] = 'filesize = ' . $filesize_kb; - } - } - - if (empty($updates)) - { - return new PwgError(WS_ERR_INVALID_PARAM, 'At least one of width, height, or filesize must be provided'); - } - - $query = 'UPDATE ' . IMAGES_TABLE - . ' SET ' . implode(', ', $updates) - . ' WHERE id = ' . $image_id . ';'; - pwg_query($query); - - return array( - 'status' => 'ok', - 'image_id' => $image_id, - 'updated' => $updates, - ); -} - -// ========================================================================= -// Database install (CREATE TABLE IF NOT EXISTS on init) -// ========================================================================= -function companion_install() -{ - global $prefixeTable, $conf; - - // Use a version flag to avoid running CREATE TABLE on every page load. - // Only run migrations when the version changes. - $current_version = '1.4.0'; - $installed_version = isset($conf['companion_version']) ? $conf['companion_version'] : ''; - - if ($installed_version === $current_version) return; - - // --- Video metadata table --- - $table = $prefixeTable . 'companion_video_meta'; - $query = 'CREATE TABLE IF NOT EXISTS ' . $table . ' ( - image_id INT UNSIGNED NOT NULL, - orig_width SMALLINT UNSIGNED DEFAULT NULL, - orig_height SMALLINT UNSIGNED DEFAULT NULL, - orig_fps DECIMAL(6,3) DEFAULT NULL, - orig_bitrate INT UNSIGNED DEFAULT NULL, - orig_codec VARCHAR(20) DEFAULT NULL, - orig_format VARCHAR(10) DEFAULT NULL, - orig_filesize BIGINT UNSIGNED DEFAULT NULL, - conv_width SMALLINT UNSIGNED DEFAULT NULL, - conv_height SMALLINT UNSIGNED DEFAULT NULL, - conv_fps DECIMAL(6,3) DEFAULT NULL, - conv_bitrate INT UNSIGNED DEFAULT NULL, - conv_codec VARCHAR(20) DEFAULT NULL, - conv_format VARCHAR(10) DEFAULT NULL, - conv_filesize BIGINT UNSIGNED DEFAULT NULL, - updated_at DATETIME DEFAULT NULL, - PRIMARY KEY (image_id) - ) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;'; - pwg_query($query); - - // --- Plugin config (default values) --- - if (!isset($conf['companion_config'])) - { - $default_config = array( - 'thumb_max_size' => 350, - 'thumb_no_upscale' => true, - 'film_strip' => false, - 'overlay_video_icon'=> false, - 'overlay_video_pos' => 'bottom-right', - 'overlay_play' => false, - 'overlay_play_size' => 20, // % du côté le plus court - 'overlay_play_opacity' => 100, // 0-100 - ); - conf_update_param('companion_config', json_encode($default_config)); - $conf['companion_config'] = json_encode($default_config); - } - - // Mark installed version - conf_update_param('companion_version', $current_version); - $conf['companion_version'] = $current_version; -} - -/** - * Read a single config value from companion_config JSON - */ -function companion_get_config_value($key, $default = null) -{ - global $conf; - if (!isset($conf['companion_config'])) return $default; - $cfg = json_decode($conf['companion_config'], true); - return (is_array($cfg) && array_key_exists($key, $cfg)) ? $cfg[$key] : $default; -} - -/** - * Read all companion config as array - */ -function companion_get_all_config() -{ - global $conf; - $defaults = array( - 'thumb_max_size' => 350, - 'thumb_no_upscale' => true, - 'film_strip' => false, - 'overlay_video_icon' => false, - 'overlay_video_pos' => 'bottom-right', - 'overlay_play' => false, - 'overlay_play_size' => 20, - 'overlay_play_opacity'=> 100, - ); - if (!isset($conf['companion_config'])) return $defaults; - $cfg = json_decode($conf['companion_config'], true); - if (!is_array($cfg)) return $defaults; - return array_merge($defaults, $cfg); -} - -// ========================================================================= -// pwg.companion.setVideoMeta -// ========================================================================= -function companion_set_video_meta($params, &$service) -{ - global $prefixeTable; - - $image_id = (int)$params['image_id']; - if ($image_id <= 0) - { - return new PwgError(WS_ERR_INVALID_PARAM, 'image_id must be a positive integer'); - } - - // Verify image exists - $query = 'SELECT id FROM ' . IMAGES_TABLE . ' WHERE id = ' . $image_id . ';'; - $result = pwg_query($query); - if (!pwg_db_fetch_assoc($result)) - { - return new PwgError(404, 'Image ' . $image_id . ' not found'); - } - - $fields = array( - 'orig_width', 'orig_height', 'orig_fps', 'orig_bitrate', - 'orig_codec', 'orig_format', 'orig_filesize', - 'conv_width', 'conv_height', 'conv_fps', 'conv_bitrate', - 'conv_codec', 'conv_format', 'conv_filesize', - ); - $str_fields = array('orig_codec', 'orig_format', 'conv_codec', 'conv_format'); - - $insert_cols = array('image_id'); - $insert_vals = array($image_id); - $update_parts = array(); - - foreach ($fields as $field) - { - if (isset($params[$field]) && $params[$field] !== null && $params[$field] !== '') - { - $insert_cols[] = $field; - if (in_array($field, $str_fields)) - { - $val = "'" . pwg_db_real_escape_string($params[$field]) . "'"; - } - else - { - $val = (float)$params[$field]; - } - $insert_vals[] = $val; - $update_parts[] = $field . ' = VALUES(' . $field . ')'; - } - } - - $update_parts[] = "updated_at = NOW()"; - - $table = $prefixeTable . 'companion_video_meta'; - $query = 'INSERT INTO ' . $table - . ' (' . implode(', ', $insert_cols) . ')' - . ' VALUES (' . implode(', ', $insert_vals) . ')' - . ' ON DUPLICATE KEY UPDATE ' . implode(', ', $update_parts) . ';'; - pwg_query($query); - - return array('status' => 'ok', 'image_id' => $image_id); -} - -// ========================================================================= -// Hook affichage picture.php — métadonnées vidéo étendues -// ========================================================================= -function companion_picture_video_meta() -{ - global $template, $page, $prefixeTable; - - if (!isset($page['image_id'])) return; - - $image_id = (int)$page['image_id']; - $table = $prefixeTable . 'companion_video_meta'; - $query = 'SELECT * FROM ' . $table . ' WHERE image_id = ' . $image_id . ';'; - $result = pwg_query($query); - $row = pwg_db_fetch_assoc($result); - if (!$row) return; - - $orig = companion_format_video_line($row, 'orig'); - $conv = companion_format_video_line($row, 'conv'); - - $template->assign(array( - 'VTK_VIDEO_ORIG' => $orig, - 'VTK_VIDEO_CONV' => $conv, - )); - - // Injection strategy based on parent theme - // BDR uses {include} sub-templates → set_prefilter on sub-handles doesn't work - // because Smarty resolves them by filename, not by handle. Use JS injection for BDR. - $parent = companion_get_parent_theme(); - switch ($parent) - { - case 'bootstrap_darkroom': - companion_inject_bdr_js($template, $orig, $conv); - break; - case 'default': - case 'elegant': - case 'smartpocket': - $template->set_prefilter('picture', 'companion_inject_default'); - break; - default: - $template->set_prefilter('picture', 'companion_inject_auto'); - break; - } -} - -function companion_get_public_theme() -{ - // user_id = 2 = guest dans Piwigo (convention interne fixe) - $query = "SELECT theme FROM " . USER_INFOS_TABLE . " WHERE user_id = 2 LIMIT 1;"; - $result = pwg_query($query); - if ($result) - { - $row = pwg_db_fetch_assoc($result); - if ($row && !empty($row['theme'])) - return $row['theme']; - } - return 'default'; -} - -function companion_get_parent_theme() -{ - $theme = companion_get_public_theme(); - $themeconf_path = PHPWG_ROOT_PATH . 'themes/' . $theme . '/themeconf.inc.php'; - if (file_exists($themeconf_path)) - { - $themeconf = array(); - include($themeconf_path); - if (isset($themeconf['parent'])) - { - return $themeconf['parent']; - } - } - return $theme; -} - -function companion_get_bdr_layout() -{ - global $conf; - - if (!isset($conf['bootstrap_darkroom'])) - { - return 'cards'; - } - - $bdr = json_decode($conf['bootstrap_darkroom'], true); - if (is_array($bdr) && isset($bdr['picture_info']) - && in_array($bdr['picture_info'], array('sidebar', 'cards', 'tabs'))) - { - return $bdr['picture_info']; - } - - return 'cards'; -} - -/** - * BDR: inject video metadata via JavaScript. - * set_prefilter doesn't work on BDR sub-templates ({include file='...'}) - * because Smarty resolves them by filename, not by Piwigo handle. - */ -function companion_inject_bdr_js($template, $orig, $conv) -{ - if (empty($orig) && empty($conv)) return; - - $label_orig = l10n('lrc_video_original'); - $label_conv = l10n('lrc_video_converted'); - - // Build HTML for cards layout (dl/dt/dd with Bootstrap grid) - $html_cards = '
' . $label_orig . '
' - . '
' . $orig . '
'; - if (!empty($conv)) - { - $html_cards .= '
' . $label_conv . '
' - . '
' . $conv . '
'; - } - - // Build HTML for sidebar layout (dt/dd) - $html_sidebar = '
' . $label_orig . '
' . $orig . '
'; - if (!empty($conv)) - { - $html_sidebar .= '
' . $label_conv . '
' . $conv . '
'; - } - - // Build HTML for tabs layout (tr/th/td) - $html_tabs = '' . $label_orig . '' - . '' . $orig . ''; - if (!empty($conv)) - { - $html_tabs .= '' . $label_conv . '' - . '' . $conv . ''; - } - - // json_encode produces a safe JS string literal (handles quotes, newlines, etc.) - $js_cards = json_encode($html_cards); - $js_sidebar = json_encode($html_sidebar); - $js_tabs = json_encode($html_tabs); - - $js = <<scriptLoader->add_inline($js, array('jquery')); -} - -function companion_inject_cards($content, $smarty) -{ - $search = '{if isset($VTK_VIDEO_ORIG)}'; - if (strpos($content, $search) !== false) return $content; - - $video_dl = ' -{if isset($VTK_VIDEO_ORIG)} -
-
{\'lrc_video_original\'|translate}
-
{$VTK_VIDEO_ORIG}
-
- {if $VTK_VIDEO_CONV} -
-
{\'lrc_video_converted\'|translate}
-
{$VTK_VIDEO_CONV}
-
- {/if} -{/if}'; - - // BDR cards: inject at the beginning of full_exif_data (EXIF panel, right side) - $anchor = '
', $pos); - if ($end !== false) - { - return substr($content, 0, $end + 1) . $video_dl . substr($content, $end + 1); - } - } - - // Fallback: inject at beginning of infopanel-right (if full_exif_data not found) - $anchor = '
', $pos); - if ($end !== false) - { - return substr($content, 0, $end + 1) . $video_dl . substr($content, $end + 1); - } - } - - // Last fallback: inject at beginning of info-content - $anchor = '
', $pos); - if ($end === false) return $content; - - return substr($content, 0, $end + 1) . $video_dl . substr($content, $end + 1); -} - -function companion_inject_sidebar($content, $smarty) -{ - $search = '{if isset($VTK_VIDEO_ORIG)}'; - if (strpos($content, $search) !== false) return $content; - - $video_dt = ' -{if isset($VTK_VIDEO_ORIG)} -
{\'lrc_video_original\'|translate}
-
{$VTK_VIDEO_ORIG}
- {if $VTK_VIDEO_CONV} -
{\'lrc_video_converted\'|translate}
-
{$VTK_VIDEO_CONV}
- {/if} -{/if}'; - - // BDR sidebar: inject at beginning of metadata section - $anchor = '
', $pos); - if ($end !== false) - { - return substr($content, 0, $end + 1) . $video_dt . substr($content, $end + 1); - } - } - - // Fallback: inject after {$INFO_FILE} - $anchor = '{$INFO_FILE}'; - $pos = strpos($content, $anchor); - if ($pos !== false) - { - $dd_end = strpos($content, '', $pos); - if ($dd_end !== false) - { - $inject_pos = $dd_end + strlen(''); - return substr($content, 0, $inject_pos) . $video_dt . substr($content, $inject_pos); - } - } - - // Last fallback: inject at beginning of info-content - $anchor = '
', $pos); - if ($end === false) return $content; - - return substr($content, 0, $end + 1) . $video_dt . substr($content, $end + 1); -} - -function companion_inject_default($content, $smarty) -{ - $search = '{if isset($VTK_VIDEO_ORIG)}'; - if (strpos($content, $search) !== false) return $content; - - $inject = ' -{if isset($VTK_VIDEO_ORIG)} -
-
{\'lrc_video_original\'|translate}
-
{$VTK_VIDEO_ORIG}
-
- {if $VTK_VIDEO_CONV} -
-
{\'lrc_video_converted\'|translate}
-
{$VTK_VIDEO_CONV}
-
- {/if} -{/if}'; - - // Piwigo default/elegant/smartpocket: inject inside
- $anchor = '
'; - $pos = strpos($content, $anchor); - if ($pos === false) return $content; - - $inject_pos = $pos + strlen($anchor); - return substr($content, 0, $inject_pos) . $inject . substr($content, $inject_pos); -} - -function companion_inject_tabs($content, $smarty) -{ - $search = '{if isset($VTK_VIDEO_ORIG)}'; - if (strpos($content, $search) !== false) return $content; - - $inject = ' -{if isset($VTK_VIDEO_ORIG)} - - {\'lrc_video_original\'|translate} - {$VTK_VIDEO_ORIG} - - {if $VTK_VIDEO_CONV} - - {\'lrc_video_converted\'|translate} - {$VTK_VIDEO_CONV} - - {/if} -{/if}'; - - // BDR tabs: inject inside
table, at beginning of - $anchor = '
', $pos); - if ($tbody !== false) - { - $inject_pos = $tbody + strlen(''); - return substr($content, 0, $inject_pos) . $inject . substr($content, $inject_pos); - } - } - - // Fallback: inject inside tab_metadata panel - $anchor = '
', $pos); - if ($end !== false) - { - return substr($content, 0, $end + 1) . $inject . substr($content, $end + 1); - } - } - - return $content; -} - -function companion_inject_auto($content, $smarty) -{ - // Try BDR cards (full_exif_data is specific to cards layout) - if (strpos($content, '
0 && $h > 0) $line1[] = $w . "\xc3\x97" . $h; - - $fps = (float)($row[$prefix . '_fps'] ?? 0); - if ($fps > 0) $line1[] = rtrim(rtrim(number_format($fps, 3, '.', ''), '0'), '.') . ' fps'; - - // Line 2: bitrate | codec | format | filesize - $line2 = array(); - - $kbps = (int)($row[$prefix . '_bitrate'] ?? 0); - if ($kbps > 0) - { - $line2[] = $kbps >= 1000 - ? number_format($kbps / 1000, 1) . ' Mbps' - : $kbps . ' kbps'; - } - - $codec = trim($row[$prefix . '_codec'] ?? ''); - if ($codec !== '') $line2[] = strtoupper($codec); - - $fmt = trim($row[$prefix . '_format'] ?? ''); - if ($fmt !== '') $line2[] = strtolower($fmt); - - $bytes = (int)($row[$prefix . '_filesize'] ?? 0); - if ($bytes > 0) - { - $mb = $bytes / (1024 * 1024); - $line2[] = $mb >= 1 - ? number_format($mb, 0, ',', ' ') . ' Mo' - : number_format($bytes / 1024, 0, ',', ' ') . ' Ko'; - } - - $result = implode($sep, $line1); - if (!empty($line2)) - { - if (!empty($line1)) $result .= '
'; - $result .= implode($sep, $line2); - } - - return $result; -} - -// ========================================================================= -// Thumbnail processing (GD) -// ========================================================================= - -/** - * Process a representative image: resize, film strip, overlays. - * Modifies the file in place. Requires GD. - */ -function companion_process_representative($path) -{ - if (!function_exists('imagecreatetruecolor')) return; - if (!file_exists($path)) return; - - $cfg = companion_get_all_config(); - - // Load source image - $ext = strtolower(pathinfo($path, PATHINFO_EXTENSION)); - $src = companion_gd_load($path, $ext); - if (!$src) return; - - $src_w = imagesx($src); - $src_h = imagesy($src); - - // --- 1. Resize --- - $max = (int)$cfg['thumb_max_size']; - if ($max <= 0) $max = 350; - - $longest = max($src_w, $src_h); - if ($longest > $max || !$cfg['thumb_no_upscale']) - { - if ($longest > $max) - { - $ratio = $max / $longest; - $new_w = (int)round($src_w * $ratio); - $new_h = (int)round($src_h * $ratio); - $resized = imagecreatetruecolor($new_w, $new_h); - imagecopyresampled($resized, $src, 0, 0, 0, 0, $new_w, $new_h, $src_w, $src_h); - imagedestroy($src); - $src = $resized; - $src_w = $new_w; - $src_h = $new_h; - } - } - - // --- 2. Film strip (creates a square image) --- - if ($cfg['film_strip']) - { - $src = companion_gd_film_strip($src, $src_w, $src_h); - $src_w = imagesx($src); - $src_h = imagesy($src); - } - - // --- 3. Overlay: video icon --- - if ($cfg['overlay_video_icon']) - { - $icon_path = dirname(__FILE__) . '/assets/video-icon.png'; - if (file_exists($icon_path)) - { - $icon = imagecreatefrompng($icon_path); - if ($icon) - { - $icon_size = (int)round(min($src_w, $src_h) * 0.20); - $icon_w = imagesx($icon); - $icon_h = imagesy($icon); - $scale = $icon_size / max($icon_w, $icon_h); - $scaled_w = (int)round($icon_w * $scale); - $scaled_h = (int)round($icon_h * $scale); - - $scaled_icon = imagecreatetruecolor($scaled_w, $scaled_h); - imagealphablending($scaled_icon, false); - imagesavealpha($scaled_icon, true); - $trans = imagecolorallocatealpha($scaled_icon, 0, 0, 0, 127); - imagefilledrectangle($scaled_icon, 0, 0, $scaled_w, $scaled_h, $trans); - imagecopyresampled($scaled_icon, $icon, 0, 0, 0, 0, $scaled_w, $scaled_h, $icon_w, $icon_h); - imagedestroy($icon); - - $margin = (int)round(min($src_w, $src_h) * 0.04); - $pos = $cfg['overlay_video_pos']; - if ($pos === 'bottom-left') - { - $dx = $margin; - } - else - { - $dx = $src_w - $scaled_w - $margin; - } - $dy = $src_h - $scaled_h - $margin; - - imagealphablending($src, true); - imagecopy($src, $scaled_icon, $dx, $dy, 0, 0, $scaled_w, $scaled_h); - imagedestroy($scaled_icon); - } - } - } - - // --- 4. Overlay: play button (center, drawn natively in GD) --- - if ($cfg['overlay_play']) - { - $size_pct = isset($cfg['overlay_play_size']) ? (int)$cfg['overlay_play_size'] : 20; - $opacity_pct = isset($cfg['overlay_play_opacity']) ? (int)$cfg['overlay_play_opacity'] : 70; - - $btn = companion_gd_play_button( - (int)round(min($src_w, $src_h) * ($size_pct / 100.0)), - $opacity_pct - ); - if ($btn) - { - $btn_w = imagesx($btn); - $btn_h = imagesy($btn); - $dx = (int)round(($src_w - $btn_w) / 2); - $dy = (int)round(($src_h - $btn_h) / 2); - imagealphablending($src, true); - imagecopy($src, $btn, $dx, $dy, 0, 0, $btn_w, $btn_h); - imagedestroy($btn); - } - } - - // --- 5. Save --- - imagejpeg($src, $path, 90); - imagedestroy($src); -} - -/** - * Load an image via GD from path + extension - */ -function companion_gd_load($path, $ext) -{ - switch ($ext) - { - case 'jpg': case 'jpeg': - return @imagecreatefromjpeg($path); - case 'png': - return @imagecreatefrompng($path); - case 'webp': - if (function_exists('imagecreatefromwebp')) - return @imagecreatefromwebp($path); - return false; - default: - return false; - } -} - -/** - * Apply 35mm film strip effect. - * Returns a new square GD resource with perforated borders. - */ -function companion_gd_film_strip($src, $src_w, $src_h) -{ - // Strip width = 12% of the longest side - $side = max($src_w, $src_h); - $strip_w = (int)round($side * 0.12); - - // Final canvas is square: image width + 2 strips, height = max(src_h, src_w + 2*strip) - $canvas_w = $src_w + 2 * $strip_w; - $canvas_h = max($src_h, $canvas_w); - // Make it square - $sq = max($canvas_w, $canvas_h); - - $canvas = imagecreatetruecolor($sq, $sq); - $black = imagecolorallocate($canvas, 0, 0, 0); - imagefilledrectangle($canvas, 0, 0, $sq - 1, $sq - 1, $black); - - // Film strip background (very dark gray) - $film_color = imagecolorallocate($canvas, 26, 26, 26); - // Left strip - imagefilledrectangle($canvas, 0, 0, $strip_w - 1, $sq - 1, $film_color); - // Right strip - imagefilledrectangle($canvas, $sq - $strip_w, 0, $sq - 1, $sq - 1, $film_color); - - // Draw sprocket holes - $hole_w = (int)round($strip_w * 0.45); - $hole_h = (int)round($hole_w * 0.7); - $spacing = (int)round($hole_h * 2.5); - $hole_color = imagecolorallocate($canvas, 0, 0, 0); - $edge_color = imagecolorallocate($canvas, 50, 50, 50); - - // Margin from strip edge - $hole_x_left = (int)round(($strip_w - $hole_w) / 2); - $hole_x_right = $sq - $strip_w + $hole_x_left; - - $y = (int)round($spacing * 0.4); - while ($y + $hole_h < $sq) - { - // Left hole - imagefilledrectangle($canvas, $hole_x_left, $y, $hole_x_left + $hole_w - 1, $y + $hole_h - 1, $hole_color); - imagerectangle($canvas, $hole_x_left, $y, $hole_x_left + $hole_w - 1, $y + $hole_h - 1, $edge_color); - // Right hole - imagefilledrectangle($canvas, $hole_x_right, $y, $hole_x_right + $hole_w - 1, $y + $hole_h - 1, $hole_color); - imagerectangle($canvas, $hole_x_right, $y, $hole_x_right + $hole_w - 1, $y + $hole_h - 1, $edge_color); - $y += $spacing; - } - - // Thin frame lines around image area - $frame = imagecolorallocate($canvas, 40, 40, 40); - imagerectangle($canvas, $strip_w - 1, 0, $sq - $strip_w, $sq - 1, $frame); - - // Center the source image - $dx = $strip_w; - $dy = (int)round(($sq - $src_h) / 2); - imagecopy($canvas, $src, $dx, $dy, 0, 0, $src_w, $src_h); - imagedestroy($src); - - return $canvas; -} - -/** - * Draw a YouTube-style play button natively in GD. - * Returns a truecolor GD image (transparent background) of size $size × $size, - * ready to be composited with imagecopy() on an alphablending-enabled canvas. - * - * $size : side length in pixels (the button is square) - * $opacity_pct : 0 (invisible) → 100 (fully opaque) - */ -function companion_gd_play_button($size, $opacity_pct = 70) -{ - $size = max(16, $size); - // GD alpha: 0 = fully opaque, 127 = fully transparent - $gd_alpha = (int)round(127 * (1.0 - max(0, min(100, $opacity_pct)) / 100.0)); - - $img = imagecreatetruecolor($size, $size); - imagealphablending($img, false); - imagesavealpha($img, true); - - // Fill with full transparency - $clear = imagecolorallocatealpha($img, 0, 0, 0, 127); - imagefilledrectangle($img, 0, 0, $size - 1, $size - 1, $clear); - - // --- Rounded rectangle background --- - // Proportions matching the YouTube icon: W:H = 4:3, radius ~18% of height - $bg_w = (int)round($size * 0.90); - $bg_h = (int)round($bg_w * 0.75); - $bg_x = (int)round(($size - $bg_w) / 2); - $bg_y = (int)round(($size - $bg_h) / 2); - $radius = (int)round($bg_h * 0.18); - $bg_col = imagecolorallocatealpha($img, 80, 80, 80, $gd_alpha); - - imagealphablending($img, true); - - // Fill rounded rect: center + 4 edges + 4 corner arcs - imagefilledrectangle($img, $bg_x + $radius, $bg_y, $bg_x + $bg_w - $radius, $bg_y + $bg_h, $bg_col); - imagefilledrectangle($img, $bg_x, $bg_y + $radius, $bg_x + $bg_w, $bg_y + $bg_h - $radius, $bg_col); - imagefilledarc($img, $bg_x + $radius, $bg_y + $radius, $radius * 2, $radius * 2, 180, 270, $bg_col, IMG_ARC_PIE); - imagefilledarc($img, $bg_x + $bg_w - $radius, $bg_y + $radius, $radius * 2, $radius * 2, 270, 360, $bg_col, IMG_ARC_PIE); - imagefilledarc($img, $bg_x + $radius, $bg_y + $bg_h - $radius, $radius * 2, $radius * 2, 90, 180, $bg_col, IMG_ARC_PIE); - imagefilledarc($img, $bg_x + $bg_w - $radius, $bg_y + $bg_h - $radius, $radius * 2, $radius * 2, 0, 90, $bg_col, IMG_ARC_PIE); - - // --- Triangle (play arrow) --- - // Centered in the rounded rect, slightly right-offset for optical balance - $tri_h = (int)round($bg_h * 0.48); - $tri_w = (int)round($tri_h * 0.87); // equilateral-ish - $tri_cx = (int)round($bg_x + $bg_w * 0.52); // slight optical right shift - $tri_cy = (int)round($bg_y + $bg_h / 2); - - $tri_col = imagecolorallocatealpha($img, 255, 255, 255, $gd_alpha); - imagefilledpolygon($img, array( - $tri_cx - (int)round($tri_w * 0.40), $tri_cy - (int)round($tri_h / 2), // top-left - $tri_cx - (int)round($tri_w * 0.40), $tri_cy + (int)round($tri_h / 2), // bottom-left - $tri_cx + (int)round($tri_w * 0.60), $tri_cy, // right (tip) - ), 3, $tri_col); - - imagealphablending($img, false); - return $img; -} - -// ========================================================================= -// Helpers -// ========================================================================= - -/** - * Check if local config file is writable (or parent dir is writable if file doesn't exist) - */ -function companion_is_local_config_writable() -{ - $config_path = PHPWG_ROOT_PATH . 'local/config/config.inc.php'; - if (file_exists($config_path)) - { - return is_writable($config_path); - } - // File doesn't exist — check if directory is writable - $dir = dirname($config_path); - return is_dir($dir) && is_writable($dir); -} - -/** - * Detect a CLI tool: find its path and get version output - */ -function companion_detect_tool($name, $version_flag) -{ - $result = array('installed' => false); - - $path = companion_find_executable($name); - if ($path === false) - { - return $result; - } - - $result['installed'] = true; - $result['path'] = $path; - - $output = array(); - @exec(escapeshellarg($path) . ' ' . $version_flag . ' 2>&1', $output); - if (!empty($output)) - { - $result['version'] = trim($output[0]); - } - - return $result; -} - -/** - * Try to find an executable in PATH or common locations - */ -function companion_find_executable($name) -{ - // Try which/where - $cmd = (PHP_OS_FAMILY === 'Windows') ? 'where' : 'which'; - $output = array(); - $return_var = -1; - @exec($cmd . ' ' . escapeshellarg($name) . ' 2>&1', $output, $return_var); - - if ($return_var === 0 && !empty($output)) - { - return trim($output[0]); - } - - // Fallback: common paths - $paths = array( - '/usr/bin/', - '/usr/local/bin/', - '/opt/bin/', - '/opt/local/bin/', - '/snap/bin/', - ); - - foreach ($paths as $path) - { - if (file_exists($path . $name)) - { - return $path . $name; - } - } - - return false; -} From bfa51c58baf3988ef0d13c54aeb6578d4d73be94 Mon Sep 17 00:00:00 2001 From: Gotcha26 Date: Thu, 5 Mar 2026 23:58:28 +0100 Subject: [PATCH 44/51] URL for Fiona --- piwigoPublish.lrplugin/PluginInfoDialogSections.lua | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/piwigoPublish.lrplugin/PluginInfoDialogSections.lua b/piwigoPublish.lrplugin/PluginInfoDialogSections.lua index 55af29e..0abf10b 100644 --- a/piwigoPublish.lrplugin/PluginInfoDialogSections.lua +++ b/piwigoPublish.lrplugin/PluginInfoDialogSections.lua @@ -207,7 +207,7 @@ function PluginInfoDialogSections.sectionsForTopOfDialog(f, propertyTable) f:push_button { title = "Visit website…", action = function() - LrHttp.openUrlInBrowser("https://gallery.fbphotography.uk/") + LrHttp.openUrlInBrowser("https://gallery.coastwisesomerset.org.uk/") end, }, }, From db696c93f00824720f7cbf94801bd7d2a20cbdda Mon Sep 17 00:00:00 2001 From: Gotcha26 Date: Thu, 5 Mar 2026 23:58:54 +0100 Subject: [PATCH 45/51] Delete the lightroom_companion plugin ZIP file. --- lightroom_companion.zip | Bin 25617 -> 0 bytes 1 file changed, 0 insertions(+), 0 deletions(-) delete mode 100644 lightroom_companion.zip diff --git a/lightroom_companion.zip b/lightroom_companion.zip deleted file mode 100644 index cd68b99d5f215208f32c3061c18446401eb65ac6..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 25617 zcmcG#1F$FEwe{w#+vxh<>-T25Xa4# zeR7}7$lQDFwNhRR1QZ4E&oWZ1r2TK5|GxR>sI`TexwE64osFK6osGSLt%aQ}o&0~d zfB+N}DrrL|;PY3*002o;008d4urM&Tv9P7JH@9EZJh!D{LHN=uzt)!zW{WSup#^SA zrYzlT4rnaT>;Xcp+tB%`(a!cX2aO(~#|_wn^guH8*0?660rT`lAe1$nKD?h;VZwV( z`gBgQwS6%}9O!d9c^!SNG;(FgnOc1{_Z56684bsW2+Os<<_u2@Mh^*}+k;)(-rQK( zfH0(^_UTPrL6(imf`L5rMHyJnwCvbXdzB0~JPF~R4$ISu(g+5Gm9?e`5JxPR^Mh&xPRcdri zxS3kS_*V3|4ea10xxoqAHexF*`U4l#5}1G+9+>nB2touXi>@>X4U`FpAooWg(lU5L zl)QV0lACuFaI;fgbj~vB&`<6sA%UsV-yFNTh$Odg4o1FUMtTMNBURG#BviRtZgLbx z9=%bFKQZb|Fs{}j#1A+yMC8hd9RzTG|3tVURcUOc+wzQR{DDZDFGwp2#xlNWXzu5< zUpfg3V{b>=X_dM~tC64$-S`{p`qczwb6gyz6Ds6Lq9Az~!ZF7_%nb97+gng73^4~X z0`tj(83a{Fi|^|)_+8KkizY9L#E#)Vrfhh)Btpy8(J|Y%-MJ`0xv$X2NLhj3s)>4Z0c0}6p2Sd zGxLA|rg%_Vq$FX5RQahi9Swz|OEQuuqPao8jglZHeLG*hhxRPU@pJWO97K~bR}sZ6 z!wbd7wLoF>NEP9Y8P`HPOq02nolaeKY4a{wp^Rvb%8H$#xZ)Mq;3qLXb%NmCEONXu z4Av+#^2>E!TIgQ)rY||<-bxr$Mltt(f>5PQ<)XjcpXtMlYtn9eOTb0L&9g}uaz6Kf z6`?Z|kxWg@JD0|6D%E1|z)kwyqQbLhWQdV&m9VHX4_Jpuf-$0)_H?!MSdbKaG6-kD zi?zbg95~0Sk{0|_+d{s*sqhY#Lt3c0CZ|8X0M3rYdHRN_hk@AGo8$&$+$}a^R zQ9^j)1>T(&3d%yF>bV8d1FTlY-k><%yL~KF8|u0BIRrIZHeZMgrcQBc-e?%Hd7NTq zH3X5q?Trg0wbT4pqvDz)OuSmiwZqW7%Fdzw;o)8^jgQj_jeB~O4iAp7H>UgYV%^va z?lrAX=&sZ|YsPabul;F6#n^0n#c$Ly+S6OYS49sMol(vp_sFzcm+Xxpl$Jt4!Ye35 zVjv+meKr2V>VEPDuYy04p9s8p-c=B8u00K!QGsQAk4oeSoMvDU{> zz<{RKq6j9gTxu>YFbTk5l7mvXhnDGp`X`IxFs`jPf66gNpMFI}kNJPghDMCt@35+q z2^X7jFt)Ml>&3!vQgqmhh3QF2MNzi`3DGr>5XGagaP;n{kT7`eWwH}!FM`^WBe}T3 z7Y%5!kEqJ$Y!oUX5Ve+|?9e6_;XU)?>7mr!E`B;{y`qA<)L`}*m=&H-N_U={s@J&J zR7kOuzwucTAaDF(6Eo8qs}XhsA`w1@sly9E3(&?00+Fs!r-h%r%Obg9G5sBnjh9NK z*8v#NNqUEf+#;ed(yInqJhB=Y>2`2R_Vv#qd)op3mPnO)jQen*sv#QL=ZaL-5afv3 zG!+P_%WQ^rWY68@?blRRffi*E<;^PJR8HaJaS@q}uxz`Ew{rLuq$;ogiiv*&VuTy7 z!(WwIE{=j@DaV_fC`#}!RCkCVyUVj{LCLQDC=WLH%u#8)I$`m%N&lrPV?e9*mZW${^e2%tm~o)s0T?8jKXICe zXf;mSV+q;~nRDMGJoq10Ht7c2H(@0%o_HlGSPTq$MG^x?Dn<^g4hWHqLG~=NksrNz zA^hB(^`&WozF%!peVNQn^Sgub6FrD^ULNyOvGtU1ITx7I;J$h$Eo^mdetHUvjJ(RT zz)x~Az|*~A0d(+XDEops#Mlw&le#9JA}$gR^2IMl5UUBmR$w54npS#6=YJ4R=X)q4TsH7UDTl) zq?f^eC8?INeXC~z_6@RN*;xpePiKCqIbdVA6^M<^^lE@7U28Q9TMT_(iF#DiJyg6) zD+O5pmXSK$56rNxm{f#QvSC%Rg=)f1mQKnR6*c~Mh(A_>z%R~Q9I9eF!zT)cA9u7C zJM(sWYUUQGa|9)orAPTao|5b;5GwwvyikTWPyWdq-jClFt(QE@Vtf+S!X(zJz(!%| zOwAF~zt6j2s&N>?Ad}BQK@gkOhx!p4Wqy4^Z70!DAR(@366M2h4)u6qUc89JqKhs1 zmFq=d6=bsv#CpQ2FgxonJf{50*WbOtZFe>QA?Z5tac-3yeNYIiD*m?lxcW=bC8GqM z)XBT-j5_e6f^rr$K3tlkzj20z!qWs_W=6=7Z1fq=U05WNkekKGYLguiGa95iynQ*@ z5g`(JP9yzYWJ4e0q(ZwJ4+f&~gxC8vcQvu>hjy1SqOK5bdo`U1*Y<%OSK78uk`J=q zyR2UpTr}eg(qdfv5S#z(?&(Xkj{`%wi|JSY{7xR( zn?CD&pjN?e5(@Q;?`J=vGOX#E){25? zZ_Bx|Vq$eVx0=>VQY1HZ8ar<)W7Y2hvnt3UbPI~XSxOTdWH(QyT{ODce@TmlHTE{t zur2Q<`#7vAE}f0L0L6A^#>s6SIkxk((H6d^cmcHGn-AI-F047lM06~6BF*!%5?A zs$Z+@VKNfXvrN|c%Xu}A6b$*-CIdSCQbRRB-$&?CChE9Ft4uzZA)iC@mLi<$6q zp3v2az7;f0oxhS*QEka7ZLgU&g{g6NQcv+J(_Zf)hs}gxRPsH|Y-+NTwmJKqz}!^a z{9MS%?($jJxg>^ar!vZhS^5YjV|K1Prr!#8p7QQ^q^n=0F)JOTt@ucnB@=g4OQ3nM zR40e+GRXAlxaKTgN$+`3%XStoE6S8rM(y@&fOwy9Lx2Po(4y9Q@DtM}YL0=KH$8Wi zbV>3`c6GVztthPd<{CfLqSJaYbAJAQ786a9?&o|ErJHkmWe@Q$$ww99(6jdT3Fb4C z8FDK0;H2eoQ>D5J;8m3G23oDKE#+YmEXC?ht<>MxxeT2LbtxbHEMXN5I2X)eP62Ru z@{xUC!2cJ{`3D)@h;CkqK?4A6!v7V{akjU<#96i7XT9m-{|-=DPw7%WUdc7z1Ire6 z*nAVbxUf~A23;*MVQHtFmDpIoU${Prb8$au@D^|AvI#gLOb~Xooq`E#oC>P9^E{X= z{iRICWF{@WT(10!_Y*YVYf=n!G2!7sS=oD+#GL^3;fr4487NMN64Ek+2J5LhS?BML4(i3Vn`<8YE zioP@2)1tj*qhDqn2Ew&YFQLXWM{1eyA*b?G{Ra*VXjomDDtX`iez=Q!o%Au>>q>Sa<-YmL>uT0T?L8-ldf%f*$tvEuPi8wD%-9^>IKcN5)XO)oioTT6*O;L!SH4 zbM!UlHdD8m_w-BdEzkZsa`z3d%dyFj)*6J?T-Hy_t+Mzy12tG==HdA7;N%fV>X@Cu z*ZLpyg6j4y1Cv3u^%s%58Qs3~tT7a~I?JMIHLwG--q+l9EaQ@~ffuy?saQ4u#w!oj zRl99gEuF_|M<(;fcK=Kk5HCQQkw1)6S&TYSBu<`7;M-AWUMW>elJ6L5QtuPfYwZ)Q>c$svI}Obq1&&~*jyAW;b`wYtI07p%@n0nf0ZVIibVav?!l>R7vDAqcfu(ZSiJ!X#H{ToAgWb`hn?#=tI z@QBv7_bn>?zVdSnDZVu1w3TERX)K%x*5GG=s1kScjn;Iar~gf1LP#wNi0KuHu^cxP zkr|3~WS6osu$uKQ3H=jM{RtUw2mG%G;cewQq;Tb$Bxc@%QvCtB^CK!F=I#RdtQ?Gq zrqcS^Da6nb?UsP(OCyQeEdSvOXne5Wvq5@-iTW&*u*sAeUaiUGcJG(E`;s<|>roqt z190ZPTpeI0kdOJ=`pGE_IS_gwp${ijjqjZK|xxS?(UjU zQEAIEkDD^keO6vBFww}#GqAz?28PzT701ZSEeSEB3n2yh$Kdj*2YG#c#`_iXXAN@) z(0R2}$s{ua^3uaWE3(ypXm2eGKsAbCdyw}SGKp5ej( z6bYYhGA=9c8mF|Zz`$m9S;Bja(D=mk+O-mAiIO^zD#Oa=vz69!5Q@X65?I6d**Zu? zRrHFUwexn)7Ooz%S2|xGwUn^heSVTw+#-asGRrt1?}!T{0hc;a0H!}!r_Op=!8TdR ztA}APdi!5wMd5t)BhNco3r@!3n#Yvf_%CmPz%MFj8~{|X7(*g|AO9H4c1*xKMge|| z?E=ehvdQPBHcO-J9@N>Z3?s&SkuC%T^8k;f3HqEli$yzu(`YChi%t{^mmfmmb7QE5gt&Wmpd3>}nKNiVT1p`dT4ojY zcq7Ub;J%lv?KoP8kVa^S9LXrXKN}b%MemKcHhxUTDO+>)#DT3sZ5$2~m6$nKC)h|U z8puE==}@fDSXfWYLW!~}V*N(_i6mvgzZBT=(q(@ZtX%! z3dR zGd{;7SHzoyTr`ABOn$CDH>h2nnoG9a0#U%V#HBIYJIbpl)NaD6zH*_HQf<$fZSu(x z{*_u?u&xyL=?a&|lp*f~B9=70Jm4bDIyeKp@LjKG%6Uss;i+U-y4pxF)Uo=4cZ|{n z0?XNT5U(1!vl zv7!Tag&py=qo)R)8EQVCRv8aAnSyf1EmnfTi4VoUbsv(BOJ~&uYrOSc2_#|Cw+Z0^ zC=v7*SUzW3C}`0rwdWU+U#EA9&1iXqV-C_wiJo1erbIuA~(aA z9Hm!lpCjm z8y38w+o-koLLFU5W7TQ3FM0~+A$HKCNyy~gB`_EZIg2fKzk5N&rziFN+5P?W=w{Yu z?XH!WfluUV#5nhW=NU^1ZCa(S$|^_-b*okU_#LTOsXEWS&2{4F$!_hE1)_ZShB0m- zD0d5LEs#@P^09)!|MB{5TZ!#CHi^!iU&rj(at~=BsATCytc-le8%I=yUgOH%EM=%Lzp>srHzuZma>^ znt0v%H}z8`_MwU4d>87OcE*gOMa-CVZKTwJYOd`%{pHdFzLko$6|+h?)-PgNmWZM$ zU0)Ea{0tQ^CIXkkAonLcrW224*6{hC)h^xfp(-xjRgVuZ)5U>~&HItF_>;~rq0B{4 z>n$j5Em*xez!n1Mqrl=Cvyup_*-{r-EwuSiBWy=(S_aY8$(Xhz1lz8pP(rblD4gxc2?vX)vJWo(RzW6~(D% z{^Th?nWf_$0{h}i!x^SpeUS8P&I!%sdl+LgsIIkD*WCXtY7_U{r8!J8Di##|!DySl zn!{(7C<%Y=B$ANKqLUoB6Re``=Fli_yci<|Ig!JaV}9Y8fUlK9qgwhI2UI23O`RWZ zV(@{@Y0qP0o%kBCK#&J5lPwC}m895?Fhk^$or@!n54>7KIr?iIO4Xyej_l8*pT7Hh z?|5-iYWdpeSyHOO9rmwQZ6zC+-nwkASEjbfU-3J|n96Z~mT%r+-tU2$35eZ3BPZP7 zSXAC#Z_Z2lNAM5MG+xTXr`ca9KU!gB**MJzmk{&GJ@#i;96`u2m5s-8v~^_o1Jiz#tMCF>K!<7^+}eN@iRqk~|0%^AN{ zI@1r3|H!}rfXe@go5228OoNk?iL=xHjiUI+(Bf<*5*7>q_&@*vNdDJ`|ISnxIXN|{ zX~oXdqkM`P!lOY%5OFhSG$$*I6TBS2LlP45ZVbrC;Bq-?ujLCGB2oh{^~$P4F2`}_ zz#Rs;iq1PsKlc?QN@(P%HIPT>E9Px)cNb0zGc+7E3VObuiwV%Y z%qDfXU2OqLz|cJ?)&-gq%PUT4v>z|{8W_;IKA0AY1EJfe?7{AX)hLp3h?RD@A*_+h zdkAVGD%ccJB0VP{%WU&y%c`DK3RpzUXrScE;jq#_3M{o<=HgXqOyo(4ZPl4)6Rg4id^ny7IkQkc8wr8^AR0gP8E-p z^+g(6!$18vqK$46I@Ij7k@Xm=Kq^RHfvDDW>Zv7`EuKL@Kri7Omxv!Zu>@Z=2}BA> zU!e!;#i?k-!BBclyYOC9%e1kzR68eHNqqXETH_w2U_Ob7p^=wz)(tYWyb&EQm;1(9)baepZC*YejaBS-&P(Wn4hDHLzY5&`cun?vdPYw|Ircvn5g^x^bno7Fd?zW+xoCpLuwRa)H znNss%T9%%^Sh9C!$(irN$Ms&e$+2lS_yKjw zVv)0G>R}PM*N6!zI|j8;$ye>E)XKMTsf@c-#^hyWQ!v=N)BCVQO^UP55#MvA$lAx_ z`O7lrwrNxjW6!C(U5iZr9<2n16&X<*5eZ_Z!m-FwS)(^qKfgh=uGp4p1>s7y7es*w zr6{VDIo1yOBIUlEPast;z^6qnCqco5DhG!K{fjO4F*2xi4Fg5YxPj~Pwud=}RXPXq zVglxU#u$|r$7m)>vX3^CJQ91$TDTmVdsbZEZRe_^^{X?aclw)BTtQyP<*DraJO_9Q0N=xMb~FyIANUY>d+$+Mzy@&SjE)eJ)blF!shxr$x`8~%8>m!pITCfv&sMdIs$+{JIb%Wp^-#a}%Xab3l9V8J9 z?~xscSrq0EgFg24hUt-XUOlSwd2ib=l@$c=OEGbK|G^K=R>aYsXahAhCWGW(EIp3xc^?2_GVL%$$0fxD8gS5&v#ovJc_zpt=rY)7U z73P%5OA&Qac)6`plMI|5HRLp^oc#fj|Vk(J4W&Z6oU-s)tF9 zQ&DKiM+dpGy($dh+Qt0#VYFxWUSJH9~uxo0!Q%Cx#ID0Ok@d})?&kJfXYzZ#N6dM86 zBr}o0Or`2O&|Gp-F4e+rT~gU?b(S7brCcYP)+)^)J^i!y%%C;PdHfD_WTLh{aXz5` zcq9OTr}O_v+y5&P-rB&{%*DXWLXYrY5#}DpLP80My@80RXSR z*B+YO%#!`v|EE#^ZU1kQf&Xa$3tM9o_kZ*N+He<&sVMkK!37It;%?#mclRIn1mfQ& z)>l2~Gyo0&9HIgMjQ@Ln*;~7q{S_+wfX;;s&^)5yaF2X(3+v%Ynhr zdK(JBf)OJBCIPS#IM$_`cTsNsW;?y^NJDBH>=a?bemzS}`)%DE8(h!lDn3sWyM!B` zdiYM*+-9$mkW*6bBjcIM(P}sM=Wgk$9=!)p$pk6X;eAf8e;zyD^?5x2!%67#m?!i1 zVC8quX;2Adk<^JS= zL)AcG4vH$OvI}baScN$&7}YqWWwBsXlMt5xgNAbXJtHC1R=i^}a_K(fA6ZFqxR`ht zleg+C7eDQR-1@!Ow=>_5D#I6`sh}U3ibO;60xlk6+Mtw2q61V*jSLP9>;Ov414ua-Guas}38 z!h2-t_aUx8BBeSbY-2^~RzxNOlnDq$kl=`Sf6`xBKq7jImKesb=VJ`!kti;WR8>s# z3y$YM3CSzS!DuAp@584zJ`Xr)uwIl|BUZmQygy#X4+6TSDMUx83!Scdu`_l-t8dJC zqfWB_d5GC{yz~2@V1eTIsvsUImgn(I9fo>sA)vRc%1%467 z&7)UI5ohhD^?RsFa(Lxsb93KUkl0U1@cYCL0S=B!In7(9xO|x&8O?m_=e+9ImUij3 zs7zldVLBBYSgj+a^PfJ9JJ?CXKNmJ7$*h4CHYHXhq54pLY-H2);1kHnMV2>nmw$vG zmt?o=d5%{vT6B&JVd9^1UI(0yM&j1+d{0b=TQSp3&4?bU($5A;8ZHnA$H=GBTMTb`0}vWB84MMdRA0i6@2)HRgMbW}i8HMesKyuh1v>$A~=TvB!e zanjA^=7|9H2)@_<9M>3V&o}5eZ|E+D##no3#D&Z~Te30^gwO=Gqb9ZeE=7s5jM-Xq zMK)$OaR$)PAZ@gJY`PudUdj!nvnVTW=hD}&L4nRkGPu#CL0dci>BxJ{=eP>)}W$cQC*P)(CO3Qi3&DMu(JhQna_IFWU0}?gaCd1q)@uNhVc1}-DPW-g8FIY zo593Hxf$3rEQTn$TvI@?T=Qh(fP%o6^Z}{Y_lWXSHj=*}>RI!6s=sZy z&AnKI?FW1|cLeQcZCpy|)tqBB=rK$C&^b8M80OpKF0>|u_K9|{T!bp{?J13t%acb;=1{7+Ja+wi$yvP#pt3(ND#4!{HGFm zSpPJp7b!(HC<2qt@L0B(gh&wuG%y{VeSN;StjEP2o!lDw-5MvTA5xF0wpVPKU>G(M z=6JY4CbnC&%9|U}hzL_ICjgCMjgJ@Ua)SI~MjZxg7`{Zq?R9YiIyg;a zd|0^I^tdoyt5Om6>_TD@wu>Hm3PS>A@zj`gg`S301<}^*ihVmgaS9#(fxYL|N77tZ)lvCX3=Y zjZ7wBe_KN=nsA!$e*hC97H}PY?bOUw)>_ekS}k6(#UlLFibkeyZ;l+N%B-hEWZWl% zp{uH(!EC>sq9dp)e|n01cYdk5Cb^m5e5Z;?23z6`#&R9&eIT~0Z;TK(#SH85vnxDa zB4r83nzLJ}?K+MiH$-;qbvdjyss6N3UJnLQzBD454}yLbtnLO_*RdL+J^emV`zfSp zuYh&b*#nf4l)24)E|Gt8y(C=WBC^SL3Qy>9b&_FVeC{K_&Tgb(O9%{{v9w^1qeiyf z|JCm&`M6w<@hz5YG%}APFWDR2XPlrRKzkgJmdC|BQ1*)s}&ZWE%7!^%=u67pVjY z`m&U-)5Me3)Tp`$NoRrK8wp@e+iuXu#XYv5&v#~rcvwE6X`yYI%x{`>nAB0N;0@0; z`Ht$h1wHfzgZy+b(cEg|d>W>U#%$DUVD^;5+wx4ezbhgwz=ogm^%C;LF8_u}0Sv|u zt0w{l2|V?nr0TkH8X$Q{Gz2tzuoC3Gt8n9AJaedvJUQ>dLky<5R9 z?mW@z?5h+FCdWWcAzWq8_ zu~K0B1@sqPD5Z|feBQw76aJhF9@Q_|Oci|g7hOU3FWFT@ob8QP=Jo?)i9jhQ@&Ea; z35IQ1eNGAzw3o7Am8;3I7|K@JgM00L^h)G1?!g=$8+0DOa%%64~nDYt0* znVmR-P{Lws%gQ*cbFkmaCJz||x)vm`QXI3Lo>d1Gboq|H;g4L7@$Puc&93y-!*@*1uhcptQMKA z)ZCKP25lXFK2|j@oE}>!D&N9)eF9a+)0$+0At?_lkFj*Tj8!UfnhahF4g>#52+CwB z*P5nHp=;LHd5^(5uRd>t1udAj?V1;oOTlZ;7V9j)(YlCX={ z1f$)JOg`Qgs&#O&Q49ZTI){@R6K`uLyTYxXgqJ>y;DnC)_G1fUx)v~9gqzIaT)UZV z@+V^E;(?;>^r1XKA^%gG(5rmcG{ou8jPs;E5s$Xq>_makb|BB6>mUkk$sH*4k((jf z@qUrlA{8pRH|V0x&*3HXW-3FcxI9qllRxM&0Xa+pl1{q>a8bhUI-JQf4A=^{hOF+r zk>c@(J=C_y&qq<8H_f(702<3B?c1)?ZT!XW0$G5AJs(#+=l+=O_8VIOw?Q0hYMY3}LismDnS+i}wCyxe21-&^^Q z`9b!`)lYr2tGa!2?$LRYjC!t7YrFvkB)-oDF@wzBINat%pW=?s2PLv++7oNEhq z*5hDNiLpD05j?S;e1AS5GsJ@)=FZ>5BN2SRu0POy zq`;kC2VpWDtX~5~Oal&7+!1g_DdX+#+1O9!!MlC>c%&R|n;7fWQzRBGkN#zXqU$0M zW*0hZZ)bI%qL%A>rk@~}=p5FSbr2*$P|MN=a}rbN-EiXeW3+1cah_?RkwNAMwzhyA z-=l7P{crl|Lig;;y2?5}=mu`-U%qFrTk~=}p9}OjA`a}<9`IXUH@6*Mr=z27-#a@W zJhhWZ%4REfThQR>{*!^+@MiZPn6+I|>0QUY7Z)C`Sh2)KBa1+mQRvjs_Yav5E;l<6 zwgQNunEb?Y%3XZ0d$&XwAT9>tox3jn23SI<|4?d6#nj z56se9TkI1)d*@D^ybaH#3f>7oEOrEysK61W{8h;dDo6V|v{fRGL<}-_3t#NHxRa9mot@^Z81Y zb@LO~w-iinaJa+I{(8p9>wSXCqEWXL@Va|iKa9|zd92^uAy(QgCrXS^wV|sOZ_(oR z8pj;mHyCXks3mt1*rsV$9dyvHf4;I&C@}K0;`DN`dqkXP#Jj%o zT|SlT04%z|60xod1e@QyUPqXtm`H-pxIGmYJf@PkYl=Bjgu}hm0c+ytPU(&)KJB)L z87urS?ymP7J%(V%M1yUDU1z#1E(5o16iKdb65+iwVsl8Jnvx{Ok57viL@3AR^ywt# z+8H>YG09Aa8B$M9lH>{J0pOI!+Eo>-{7NJ_?-OuSUS=Y`Ke-@ip2-TJ)V(1M+y*nD zf#vc~dimuB$t=pK{9Ed3rMuK^FiaOeT(VFy&xK~`R>);cwIO+GKyMdAY@xEY^zxIa zC*=A)uuOL@^WOAAQL%v7`Kp1LaZ&_hO=nDAW)Vo62$@lwEqVv5kT z(=m{G=ZFtgYEo+ zxE-ky@t#d4)@ik|rLgwsLb$tB2`kUf1H-;CX&EXhG>T{_J$?KfzaH8kW~jZ+$->ft zAD^E!NugC;c|y)7<}y|P}=5`>E1b) z3`b9I@uqHnpWHlLoE-SpA5H1gr|G^kzdb|UR8)}#E1VDNyQ6V0*^Kqj1kp?E*azXw z7a~1j^@$91lr@1-u!Y4B{*KpyNE1YvM(6N5YFI`~-WF+9L{RG(Jy6;s#YN1!J%Fk4`X=_sx5n@p`6x7w7g^0EgvZ z6EN5!>JCq;T>1STJ530G1PQ_ugn4Ww`|$ZA?3yvd#N)u3;~t}4yYa{Kc^v^EFP+ho z!-}7LEE(!UQJG!N-0<0tS7(bn#t z^b&Tyy?WZ2m6abn8$sL@Q>2ZsseO{MVrd>~#{ATqsvgHL5DihIDBy04G+r<4SULUG za2@Dur1hO=?GESG$-6M9|&jbWAiw#kOE*JdY^*Dh{D5kPon{DGS$3gB|7lKuI?Vj zH)SP=>67BTyN%UZ;^ITyi73)bh@@%B=|l4i6YsPF)l*za_Vxin=nNwYJkn&Xb2dfs z7VncsbOubBUCt4DqwW)bPM}JC581W=I}G1YuWo{FLE|Gzq}Zg;>G{qauW7x`h^92Y zh=UxBn&xvf#f!2E-L4wiGLHvj`Ay-~e7-Ft?KSkAc_>!OKmzOYyZPgJy|6(VJ2xYt zsv^(D&jDy5Gc}?*%-L@ptQi=Sdu_+Yd3y*| z9Q%CKL3a_;jzMG@X0dUyQ;~_LqvU^}=ISs)Ud+Nu^^e2wzMrOLw>Z9Eb{^u{3de?J zg$tnbf94j?_${$-<;Zo}||5p+)R)`%PXH9=|ZbBn46| z+LvSv@vS`89t6ugLxsa`mNE4G*6ss{ANCP9u72fq=Fe6KgxkU$5_3U--hsL0=hAER zW^n{HW5)G%^=U_xIp-OeIO$S^>C~EX7gI$&h_=s6Gdt*1rX_`MMo@#NU2mFBWl!K41G=tBAwSahMAbGcP$a*0L=|vJ4>?Vz zgaWmjvCr0z1+!P0cm(P4oNfH6C>wOrvgAtTaQkFtVqjK^SKh`}nLod!Lv;?~zL^V( z88d@aCi+)Q4?h%$@PNy^^|*_+6wbzNt!j5iD-r}#QumR#t013G#$$UtJ*!Q6EFg zq!8B`94&5*5WZxN3EJA!k|?am^ohQdUqH4_;ztqojMAL;R@lAajP&b}4I z`_ty(ht@;EtcW%HSqFfe(cAOkf=~VU?xAz$wsFisIA*$;8$}gC^}7SbJ;?kD9Kmbd zqHp}(RNzL;06PUc$h&ol+I)ZwL@&HbVWHttCAQq4Gn^6aby%jdVVxGvfFk@B=O~C4 zPN<@`u*E4j1zzFFYEtWPfxrPn56M}&qI3@p1>Zlz%%Z#aMwyhb~Kr>wV}*0fl4`86|_cB z-n~ri4=<&>tVb)6SAJu5?_AI=@cC&$lPGO~`b*@*1LHmUr^24(5R{<`l#j8a9 zyvcUl;pR#}N7%y#nrk%u%+}29XkJL}o5&)sh5?_9FAu-__xrhysj*x!7H{2T<*pLM zINRNBt_Z6Rx!m7h5Y9Q?U%L9|g+hxI(8<>0OoedKt_C8PmWlE!Js1>UYKNx0ejCeChd z#g?vo1`8Spu+@>O4%Ofvq9M&vkM3*}402dujx97-ps+t~-ZM9NYJ)KP;vLF{B#hhn zpJC@GR`~0!8b4{O=q_UOla31>o7r0Q&TMRZzaN~OtbVUw`tst}VqT&OlizPxCQ+)^ zhv7N?_`Je0f*i21V7xSCxjmee(Li>2b%8yITu`obYAop|svKx!;tKjxg>US#ZD02Fh`|;Q?*AuTlO}%x|wrnNR^4$iz$gMRiY)d?X?`$98x2WO zRUe&2bFo@2vf8R8Yyy?V@9I?%FnVK1J)c(-JX6ZWG)W3Ws`KVlbSSNId?v^Mhhn-# zL3&%8DKjU%g%d>qELD-MAs70%DwTEQ4qy>$y|I!;){;>`pLGc<@)f{pjpqTl`3RGB z*&GhNhbm`JSp+%~_MG9e$ub1?B)bUrO=jOI@2gOD3ok)f`KLhrMZ5#-N4GCWldRR0KafJ@<0JD*i9*li6 z%G?27!P-oQq*ZTf;>wT}$Io!qyL7fe2nyX~**oO$Dr43HmUjyO=loELQ}WW0-Qv*` z!PGyR#`Wu(Bi5VM=$ZZOiuda^D<8fY8y^uU*?KJT+E@5U4SyvTMa%gN@F7e_`1e2T zqBbXnSdRo}N$cdeDsfRqhGUVag_T8J0yRPO9!R4EFl2Bkd78nDgshxGij5-rnyhDP zR-^KqfD$$Pck279aSvKy{z{#JK~9VZ;=oVBW1etvM#S5S_LFGl%_pdW4e?k!YUx$r zjhJ5aQqENiD-VGLb%fOBTO(&36lL4CadPPeBn1Q(kd*F{ zZlt@r8wp8SxZV&h6i5(EOTk9-Ie?`dB;`5rJCXUhM zw7hx-O0`LU&4Uj}e{+8-gA_Et+C!a_ilpV&2*ZCMxGGl98Y8BEVqY3B(b z0eyYtL>w)Elk>GSCslESD>WdFe}hCRKI+vw3#o2N@P0bB zgl>U^Kbj3LaF6QsY{kG&-_0{>t?DFaMknPZodoYpKxv8R76K>Oq3Y6WE2%u39J0B|I^5 z1}FQGeDPf8xveWGj+GOWm?TZDGmOJ}il|LKVj_F-B;v8lNJoTJJl_!G6*o{z49^lX zlM*6b5e)m9D6s04rBGX*TOAnXTBu3?cA!&zv!>w85;4?`(sJUXGUsy@b6_zFc_WR+ z>RUY_s5D?FE!PsR(5s%ZIc9y_PZM#-&jaIetK>=PR|*GWVgX#_iYEW1!kY8u`q{O7 zBDsla=V<&H++LCEctmc0pO91=TQ|Q(<2svFAGTa5WMkwQynEpH=*X!x=BaM%^&};4 z=gGPVQNV@>LEHE{+JH4uuFtG75wB7boQ;K7R7RrEOp#t}uuN`69F5mMSJ8~&Gmuub z6ocS4`3S6wk0nXMN#pFY(L(4<=~*5jtXdc>8XykIfqiMNHzujM8HYe_vO0@n8|%fE zS(>jfCS{lC^kW*fm~1pk)Of@TtnjAE^{CUZ3o+}vCWe(}SMglOlX(=6^X4Qc6a8%w ztm~TXtBMM49u{vPCm{%-@x=V7r7kM#MD*;(#2nUH2OR&b(|kqcfl|J~%B(jp^rJz- z8`F`7-TgUP%mbcY1c(d>&mzA&v?hO8%#&v}$Zr_dmuts-`z3^9$&>C2$pUA9^` z?o*LTE0X#Q7(@lq6Cu7PZ_kxC8Kf0#PyWqH=t ze8dbf$%>4QV;F_-kD`Kj{@)WB_cM76!&4>%I+8+(MV~iZZ+R@TN7WQX$Q!GWN@F9p zq$9wD$h__*-ByuPX?b5v2DCD{wgV`v5fQw8ta{)Men?uEH`!=p7Rt?2%k&Jb#lMF_ z9XP^;N@r;=4mvFrVQ)zi`NJNY z7rVr!&slSk2NCd{>LECW6>5X~;U_T!v!e}LX5>%G1Z^rpCwNxNEr_q8JBGg55q72O znT?3f2C+hOzo;5r5w{r39@^SMDzMRZXIaoMu$p|xQYJP*<3!(G0N=j6^OpK%XX{Z` z3ZLBo+vEYM1SOz7i=O8T_$W}q zShHL?Upo|`GNqD-%U=siackazb=RjZV7H5K5!=bIKAX7_BM%_s5AN7>%MzQC)l)VJ zZsoc(pif;?o}Wh<1V|C14>3)z2WId!eYw;$vGd{?wi60-ju-R2KPq8MJj%vVUj2X(D}(zf=}L7E%G=v_TC+=q=xi@C!kdv^i0ceBbQOAHg}PPwb#F?&KI zCG_Q=P;bVvKb81D6tC+VT~pS6$-q_1B) z>TXgaN~Yw>rhggC_G;8N0t=tMXu1Zdjo85;UBw{|mJq~MmT2y-!|k@;95F)$SvL)K zi%^vFluHvSM1G}zrnoT5*0T)9RlM}XiY3PxpqSec54R+deQnz>GDHxGkaA)ip_9P@ z8?UKp^ZiJ^2E#%TbIW~fLA||ct+D-38#cqXID<=2ObZc1$+7vM=fYA^D9_!krptIb zrJb#7oLES)WT|NhUjTO8m`!Vm6wSxC3$*BPaC!p8ig_7=OBW(XxgODdK@}XD1?MKb zdl)p{v43O)qou{TEJ#Q>Vk(5tw&kY8Q-r$F2<5n>!Zg_IS=nzL!9!s76bY3$79kRq z>ZBW?yGSV}AOtT}riY|K4WN9NjZD+CAnI)0PT&HOgmv;_sxUlS^ zapp=8KOo0_=qE$2ueNZF3DsG0u+hTzRkn_`;$x`y_jzG|T{0{ynmF8CafB;bT)0m& z#h{fqSAMAe)~r7?Obj0TgTfa;vi0k3bn47RBCPw7Ho@_;IWzfi zTQ5fq(bM6|Sb9$O@(Tj%fj)3@`q={^PCi0q5^qODo36-7+iKno8zeUYMmK1?4(xD5Oi7ZLC32*AIhtb+TFNa;^ubNjU(0A9qJ#X0}?17 zeWH<1ze8alhFF1S2nkLOV>Y~KXq_@Zv`G?d}n;UXLR)vXkqPIxMg%4wXj$e z2zb(vFX2gL2`zz52+Zq>PgxI9xQ@#F9ge@Bwaj$eh`xG3%$$<~m@)CId zMejov?HDQ@(119p8Y`)S{*BRRanb2ft#Sx)m1v}ncyymbZgox#*dE7vIZD;5Bm+x< z%#Asm*~E{edp|#^+2^E4hu02)z*Wcnf|LC`%e38Cm)|E7sRc|-Zp1IUtankC@aW-| zrVB5tp(^#+GI}YqqNa&sYd_b7{8}!NNS0zQ)^UfdCn@I6&{8i0O28Ru00W=b&x57z zVbZp%O`=C?xvPN`>x^hja-MoUm=5VadAZZMWe>2zRbWoe; zGlq>fkr&fG1aI|{9QRnNph(BhKG-j%j|G#ta4UA8&YEj9tPj7pX+Z@%MqCZMI1S+( zijrk|d+NmeHGgA!(JY^y$10Yb->yXI+BtVep(-vuouJg#48 z@!q20D>@0?)S7C4ikT*PbQm?IupBlO!v{)cmT68w2brO=pZKxz&3K}2VKr6KT}4VH zLf(7q&7c8@Gbkf%ZHL^z!#p&v6JGnwBQX&^5;m0=thK0I!9&S-j$Pc~_QArmb$Ncn z9LcG*o18mtds$Y8ifM~y0}wB0JraYeK)pOtS&;Wt|yN& zX2nLf>*)agRLqlD&CPj**gT^f!CCAa zyukvP@8Rfx;bAPt?^uCS`MQN~mDu-l;k%|tEyq>aW^@UStzTD|+=s7-h;-AdY2JKqy|Y9&m!@@eFH#~dBE&iquZtn>=^~_@ zPaQ+qRH?B9x3&gxvIcS}+B#)UGq*aRS+>MWY#!{Q_=T2{_DsLAHYKY9d;rqbIhW31jv^Ae1u#_M-lM9?($>~j*H zY1HPQ@##0tb4@kRLdgnq67k>WC3j;R;UHN(d>%rC^!D2dXfEz>R^Yj!30aJj>iJx! zBd*R`&(P~~Q)>OfDq%~L5$malQCLT$+7r3F1f&S&@Lru!2$tQ)t3l|reP3Wncx3C zg^~E;X9dCv{L0Rd$s!XlMO4yF4}p*+L2vWr%fXVqy!L`Oy&6#v=Z33~f#@mRUy?_G zihIwwXYFf2^Ngu}Cc~6(^(I(h1UUILfvsCG59f>FT!>7n^cbgU5nplPn$31MF?FU% zmxe=h8Yi?Ffg4SX&-(h>Y&-3g&sRJ42ld_Y^&&v&6mZN836KcouUqun&Ves%{VB*+ z0j#09jNy9=t4K&HmuFxMkS-zoE8@hd5YBLl09!l*;F_ZARW1PCD95~7=U)q$Q^m|HY-cA)<4%i*COjc)B` z_5_($XJgw7?c{wA_XuqMPZs@V=vSpfY^6A={N>aFsP=Vr(Q|$L4Fj;Hn2sz1?e8NG z_x9I7W?ljIt}W(=p1vSh)?Jna`0Oki+Q!06oK;g(L`atAgWWVI9PK9x>R$qz&*ewa z2;sho%4#n$BjiJr`t%q9Z#LgvM*>GxytWmMlgl|~lB3ivBY5yYwHWd%qI|N~SQ+vq z>%|eS!KQ2!w$Q4BhP;euV?K$^)M6r(2Jx94&97}>*;o+aYN>+?z(M2#xlRIHplTrt z_mc29E>cmom%=%x8kSZ^hKDRe*A!K1hzlQ<|7^4afJ8-k9J<1iE?r86)r;no`e;pD+`|A z>q5HkNXhe4XaM_pc^^hp~Dni3HEkp_;n2E*l^k(Q1R+?*<7B^A{3X|_%* za{#X6+XDLD$H9>-Sn3Hz$u(suT&qHf*-hQ56XjqX7mp3W_k=U*;@t+6W~qhQiin^Bjtn0Ia%r z1Xw~OiH)P$LMap|P!7mwDhlH1`rtBH^pWY3evmPFk%K^Jc_dxSK@4`Rus-jGa2tDuP&e5={ihoYE#3JW4$( z<-UQYR4v$mpdC*;SnfiW7>dX`|W++M7ZrS zqPBw{d~@rSG?wuQ2AZ0{Yb)HcbihPv7=Od(Wbw2%jABGuX^l>@L=}TWuZPD{3!O@> zrUrJ|!S#moYM1;@Og}z6ZN}*JcX~c0Pbk;RFeqx$d^WI`5Ikm^f54|rP7pfqL%&+f zOIsVaF8oj}Y{m1_5yuMfy+oBv+|85Cv#y9wkZjNFcTfQk8k>ZF*q&qOUbGnpn1eUd zDe>bG@)}M%j-v6yvZyuks3V%B)^CrG1&Q_t;JVV`4msClqy_ZSI55^>-bT}=<5nVR z7)+0FV83{gj)R>SM==;kNe!iC9(`}3&?zw5Y2!KmQ)RgE=i%VFBJ@R*x#w{UJd-|!q!HB@D&5%xp4lb7<$(M zPUApdt#f}xwRE6?jFc>2UoVaS9Ju2|KL?TbtJsX?G;~kl^x!lt@l<4+sh@MfXXk!%B4);xZ60=OiB@R%EJy){pmI~WMG%Lp?IXd9+Qty^5h;~2H$T0vP32r;s#6f8Aq{5$r3V!Wjh^hAhTaoerDTVFEU?-WDZj#1$ah=i z!bV#Wa!N1hC$%5-v_CD=5R0pM)kW zW`J!(CL$qS2_E4`PDjdP5}$HbG71vWg(;Uj4QFA!J#5@%B6`vuv;IIOqSKzRC}J&Z z(Rn>ZBJZ^?smb|*^)>Y1bmXfqPu!2pKyRtuH;3I6WboZz>5rV)<|%P87eB~SHP3s& zmmO7Kq;OErPqA%>2qdM-Q=(SMeVm{h-v-B8THq(U>F!!7(y7nd{L{Z z>^a!GbsNsfI_;+ZoAReD^*ajr4ddS#nEyJdd>=>O^Al$B=IiC8w_R_sclCYTKO+tA z%W4cbdz6*tega(zlSi z6^{O&QvNgZon-}pC`TKmzj5%221NBmPX!hI?g>36U0$U5 zFADs2Jl=;EVJcnR%6_+ozMi_b*Z+&`t->Q7Keh3L`yZwJql0&@7$AE#sym_tO Ke8Shit^N;7x(KKM From ad1179e158832992587cb5b85d108685b01b0592 Mon Sep 17 00:00:00 2001 From: Gotcha26 Date: Fri, 6 Mar 2026 00:22:40 +0100 Subject: [PATCH 46/51] Solve some little warning --- piwigoPublish.lrplugin/PWSendMetadata.lua | 4 +--- piwigoPublish.lrplugin/PiwigoAPI.lua | 2 +- .../PublishDialogSections.lua | 2 ++ .../PublishTaskImageProcessing.lua | 10 ++++---- piwigoPublish.lrplugin/utils.lua | 23 +++++++++++-------- 5 files changed, 23 insertions(+), 18 deletions(-) diff --git a/piwigoPublish.lrplugin/PWSendMetadata.lua b/piwigoPublish.lrplugin/PWSendMetadata.lua index fe9492e..b6fb060 100644 --- a/piwigoPublish.lrplugin/PWSendMetadata.lua +++ b/piwigoPublish.lrplugin/PWSendMetadata.lua @@ -109,10 +109,8 @@ local function SendMetadata() end callStatus = {} local metaData = {} - local collectionSettings = {} - -- todo - get collections for collection this photo is in so collection level overrides can be applied when building metadata structure -- build metadata structure - metaData = utils.getPhotoMetadata(publishSettings, lrPhoto, collectionSettings) + metaData = utils.getPhotoMetadata(publishSettings, lrPhoto) metaData.Remoteid = remoteId -- get keyword filters from publishSettings and build include / exclude patterns diff --git a/piwigoPublish.lrplugin/PiwigoAPI.lua b/piwigoPublish.lrplugin/PiwigoAPI.lua index 115bd2b..9693abf 100644 --- a/piwigoPublish.lrplugin/PiwigoAPI.lua +++ b/piwigoPublish.lrplugin/PiwigoAPI.lua @@ -1470,7 +1470,7 @@ function PiwigoAPI.getServerVideoSupport(propertyTable) -- 1. Get server infos (photo/album counts etc.) local infosResult = PiwigoAPI.getInfos(propertyTable) - if infosResult.status and infosResult.result then + if type(infosResult) == "table" and 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 diff --git a/piwigoPublish.lrplugin/PublishDialogSections.lua b/piwigoPublish.lrplugin/PublishDialogSections.lua index cd29506..15985c0 100644 --- a/piwigoPublish.lrplugin/PublishDialogSections.lua +++ b/piwigoPublish.lrplugin/PublishDialogSections.lua @@ -22,6 +22,8 @@ along with this program. If not, see . ]] +---@diagnostic disable: undefined-global + require "UIHelpers" PublishDialogSections = {} diff --git a/piwigoPublish.lrplugin/PublishTaskImageProcessing.lua b/piwigoPublish.lrplugin/PublishTaskImageProcessing.lua index e36f33d..ad078fe 100644 --- a/piwigoPublish.lrplugin/PublishTaskImageProcessing.lua +++ b/piwigoPublish.lrplugin/PublishTaskImageProcessing.lua @@ -22,6 +22,8 @@ along with this program. If not, see . ]] +---@diagnostic disable: undefined-global + PublishTaskImageProcessing = {} -- ************************************************ @@ -692,9 +694,7 @@ function PublishTaskImageProcessing.processRenderedPhotos(functionContext, expor if not existingPwImageId or forceUpload then local metaData = {} -- build metadata structure - -- need to add custom collection settings for title and caption - - metaData = utils.getPhotoMetadata(propertyTable, lrPhoto, effectiveCollectionSettings) + metaData = utils.getPhotoMetadata(propertyTable, lrPhoto) metaData.Albumid = albumId metaData.Remoteid = remoteId -- run to build missingTags - tags that will be created on upload to Piwigo @@ -952,12 +952,12 @@ function PublishTaskImageProcessing.deletePhotosFromPublishedCollection(publishS thisLrPhoto, publishedCollection) - if pubPhotoExists then + if pubPhotoExists and foundPubPhoto ~= nil then -- photo exists in another album in the same service, so update metadata with new image url and id for that photo local remoteUrl = foundPubPhoto:getRemoteUrl() or "" local urlParts = utils.stringtoTable(remoteUrl, "/") local albumId = urlParts[#urlParts] - local albumName = foundPubCollection:getName() or "" + local albumName = foundPubCollection and foundPubCollection:getName() or "" local imageId = urlParts[#urlParts - 2] local hostUrl = remoteUrl:match("(.-)picture%.php") local albumUrl = string.format("%s/index.php?/category/%s", hostUrl, albumId) diff --git a/piwigoPublish.lrplugin/utils.lua b/piwigoPublish.lrplugin/utils.lua index 1d5ca02..b957cec 100644 --- a/piwigoPublish.lrplugin/utils.lua +++ b/piwigoPublish.lrplugin/utils.lua @@ -1329,20 +1329,25 @@ function utils.extractPwImageIdFromUrl(url, expectedHost) end -- ************************************************* -function utils.findExistingPwImageId(publishService, lrPhoto) +function utils.findExistingPwImageId(publishService, lrPhoto, excludeCollection) -- Searches if this LR photo is already published in another collection of the same service - -- Returns the Piwigo remoteId if found, nil otherwise + -- Returns: (pubPhotoExists, foundPubPhoto, foundPubCollection) or (false, nil, nil) - local foundRemoteId = nil + local foundPubPhoto = nil + local foundCollection = nil local function searchInCollection(collection) - if foundRemoteId then return end + if foundPubPhoto then return end + if excludeCollection and collection.localIdentifier == excludeCollection.localIdentifier 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 - foundRemoteId = rid + foundPubPhoto = pubPhoto + foundCollection = collection return end end @@ -1350,13 +1355,13 @@ function utils.findExistingPwImageId(publishService, lrPhoto) end local function searchInSet(collectionSet) - if foundRemoteId then return end + if foundPubPhoto then return end -- Search in child collections local childColls = collectionSet:getChildCollections() if childColls then for _, coll in ipairs(childColls) do searchInCollection(coll) - if foundRemoteId then return end + if foundPubPhoto then return end end end -- Search in child sets (recursive) @@ -1364,7 +1369,7 @@ function utils.findExistingPwImageId(publishService, lrPhoto) if childSets then for _, childSet in ipairs(childSets) do searchInSet(childSet) - if foundRemoteId then return end + if foundPubPhoto then return end end end end @@ -1372,7 +1377,7 @@ function utils.findExistingPwImageId(publishService, lrPhoto) -- Start search from service root searchInSet(publishService) - return foundRemoteId + return foundPubPhoto ~= nil, foundPubPhoto, foundCollection end -- ************************************************* From b12a14ccd4095a560502951a293d3784a8bf047b Mon Sep 17 00:00:00 2001 From: Gotcha26 Date: Fri, 6 Mar 2026 00:33:38 +0100 Subject: [PATCH 47/51] Solve some little warning (VTK) --- video-toolkit/src/cli.py | 20 ++++++++++---------- video-toolkit/src/ffmpeg.py | 1 + video-toolkit/src/presets.py | 5 +++-- 3 files changed, 14 insertions(+), 12 deletions(-) diff --git a/video-toolkit/src/cli.py b/video-toolkit/src/cli.py index 0119dff..b9e6e1b 100644 --- a/video-toolkit/src/cli.py +++ b/video-toolkit/src/cli.py @@ -107,7 +107,7 @@ def run_process(args: argparse.Namespace, cfg: Config) -> int: _json_error("--input requis pour le mode process") return 1 - preset_key = args.preset or cfg.get("default_preset", "medium") + 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) @@ -339,7 +339,7 @@ def run_status(args: argparse.Namespace, cfg: Config) -> int: _json_error("--input requis pour le mode status") return 1 - sm = StatusManager(args.input, cfg.get("vtk_dir_name", ".vtk")) + sm = StatusManager(args.input, str(cfg.get("vtk_dir_name") or ".vtk")) state = sm.get_state() source = sm.get_source() variants = { @@ -530,7 +530,7 @@ def _menu_process(self) -> None: pause(self.c) return - default_preset = self.cfg.get("default_preset", "medium") + 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() @@ -755,14 +755,14 @@ def _menu_config(self) -> None: print(self.c.box_header("PARAMÈTRES — Configuration générale", width=70)) print() - default_preset = self.cfg.get("default_preset", "medium") + 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 = self.cfg.get("hardware_accel", "auto") + 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}" @@ -910,15 +910,15 @@ def _edit_hardware_accel(self) -> None: 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 = hwaccel_mode or cfg.get("hardware_accel", "auto") + 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=cfg.get("poster_timestamp_pct", 10), - thumbnail_max_width=cfg.get("thumbnail_width", 1280), - copy_metadata=cfg.get("copy_metadata", True), + 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, ) @@ -929,7 +929,7 @@ def _json_error(msg: str) -> None: print(_json.dumps({"status": "error", "error": msg}), file=sys.stderr) -def _format_size(size: int) -> str: +def _format_size(size: float) -> str: for unit in ("o", "Ko", "Mo", "Go"): if size < 1024: return f"{size:.1f} {unit}" diff --git a/video-toolkit/src/ffmpeg.py b/video-toolkit/src/ffmpeg.py index eec31eb..632e898 100644 --- a/video-toolkit/src/ffmpeg.py +++ b/video-toolkit/src/ffmpeg.py @@ -86,6 +86,7 @@ def transcode( 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 diff --git a/video-toolkit/src/presets.py b/video-toolkit/src/presets.py index 3dcf79f..18b5e55 100644 --- a/video-toolkit/src/presets.py +++ b/video-toolkit/src/presets.py @@ -159,9 +159,10 @@ def load_presets(self, path: Path | str) -> None: def save_presets(self, path: Path | str | None = None) -> None: """Sauvegarde les presets utilisateur dans un fichier JSON.""" - path = Path(path or self._config_path) - if not path: + 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 = { From 26f67294269d76d91747997480e39f867ff1856f Mon Sep 17 00:00:00 2001 From: Gotcha26 Date: Fri, 6 Mar 2026 00:54:52 +0100 Subject: [PATCH 48/51] Revert "Solve some little warning" This reverts commit ad1179e158832992587cb5b85d108685b01b0592. --- piwigoPublish.lrplugin/PWSendMetadata.lua | 4 +++- piwigoPublish.lrplugin/PiwigoAPI.lua | 2 +- .../PublishDialogSections.lua | 2 -- .../PublishTaskImageProcessing.lua | 10 ++++---- piwigoPublish.lrplugin/utils.lua | 23 ++++++++----------- 5 files changed, 18 insertions(+), 23 deletions(-) diff --git a/piwigoPublish.lrplugin/PWSendMetadata.lua b/piwigoPublish.lrplugin/PWSendMetadata.lua index b6fb060..fe9492e 100644 --- a/piwigoPublish.lrplugin/PWSendMetadata.lua +++ b/piwigoPublish.lrplugin/PWSendMetadata.lua @@ -109,8 +109,10 @@ local function SendMetadata() end callStatus = {} local metaData = {} + local collectionSettings = {} + -- todo - get collections for collection this photo is in so collection level overrides can be applied when building metadata structure -- build metadata structure - metaData = utils.getPhotoMetadata(publishSettings, lrPhoto) + metaData = utils.getPhotoMetadata(publishSettings, lrPhoto, collectionSettings) metaData.Remoteid = remoteId -- get keyword filters from publishSettings and build include / exclude patterns diff --git a/piwigoPublish.lrplugin/PiwigoAPI.lua b/piwigoPublish.lrplugin/PiwigoAPI.lua index 9693abf..115bd2b 100644 --- a/piwigoPublish.lrplugin/PiwigoAPI.lua +++ b/piwigoPublish.lrplugin/PiwigoAPI.lua @@ -1470,7 +1470,7 @@ function PiwigoAPI.getServerVideoSupport(propertyTable) -- 1. Get server infos (photo/album counts etc.) local infosResult = PiwigoAPI.getInfos(propertyTable) - if type(infosResult) == "table" and infosResult.status and infosResult.result then + 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 diff --git a/piwigoPublish.lrplugin/PublishDialogSections.lua b/piwigoPublish.lrplugin/PublishDialogSections.lua index 15985c0..cd29506 100644 --- a/piwigoPublish.lrplugin/PublishDialogSections.lua +++ b/piwigoPublish.lrplugin/PublishDialogSections.lua @@ -22,8 +22,6 @@ along with this program. If not, see . ]] ----@diagnostic disable: undefined-global - require "UIHelpers" PublishDialogSections = {} diff --git a/piwigoPublish.lrplugin/PublishTaskImageProcessing.lua b/piwigoPublish.lrplugin/PublishTaskImageProcessing.lua index ad078fe..e36f33d 100644 --- a/piwigoPublish.lrplugin/PublishTaskImageProcessing.lua +++ b/piwigoPublish.lrplugin/PublishTaskImageProcessing.lua @@ -22,8 +22,6 @@ along with this program. If not, see . ]] ----@diagnostic disable: undefined-global - PublishTaskImageProcessing = {} -- ************************************************ @@ -694,7 +692,9 @@ function PublishTaskImageProcessing.processRenderedPhotos(functionContext, expor if not existingPwImageId or forceUpload then local metaData = {} -- build metadata structure - metaData = utils.getPhotoMetadata(propertyTable, lrPhoto) + -- need to add custom collection settings for title and caption + + metaData = utils.getPhotoMetadata(propertyTable, lrPhoto, effectiveCollectionSettings) metaData.Albumid = albumId metaData.Remoteid = remoteId -- run to build missingTags - tags that will be created on upload to Piwigo @@ -952,12 +952,12 @@ function PublishTaskImageProcessing.deletePhotosFromPublishedCollection(publishS thisLrPhoto, publishedCollection) - if pubPhotoExists and foundPubPhoto ~= nil then + if pubPhotoExists then -- photo exists in another album in the same service, so update metadata with new image url and id for that photo local remoteUrl = foundPubPhoto:getRemoteUrl() or "" local urlParts = utils.stringtoTable(remoteUrl, "/") local albumId = urlParts[#urlParts] - local albumName = foundPubCollection and foundPubCollection:getName() or "" + local albumName = foundPubCollection:getName() or "" local imageId = urlParts[#urlParts - 2] local hostUrl = remoteUrl:match("(.-)picture%.php") local albumUrl = string.format("%s/index.php?/category/%s", hostUrl, albumId) diff --git a/piwigoPublish.lrplugin/utils.lua b/piwigoPublish.lrplugin/utils.lua index b957cec..1d5ca02 100644 --- a/piwigoPublish.lrplugin/utils.lua +++ b/piwigoPublish.lrplugin/utils.lua @@ -1329,25 +1329,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: (pubPhotoExists, foundPubPhoto, foundPubCollection) or (false, nil, nil) + -- Returns the Piwigo remoteId if found, nil otherwise - local foundPubPhoto = nil - local foundCollection = nil + local foundRemoteId = nil local function searchInCollection(collection) - if foundPubPhoto then return end - if excludeCollection and collection.localIdentifier == excludeCollection.localIdentifier then - 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 - foundCollection = collection + foundRemoteId = rid return end end @@ -1355,13 +1350,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) @@ -1369,7 +1364,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 @@ -1377,7 +1372,7 @@ function utils.findExistingPwImageId(publishService, lrPhoto, excludeCollection) -- Start search from service root searchInSet(publishService) - return foundPubPhoto ~= nil, foundPubPhoto, foundCollection + return foundRemoteId end -- ************************************************* From 4119f3d881ae8824d25736425dd1f340c626ec1a Mon Sep 17 00:00:00 2001 From: Gotcha26 Date: Fri, 6 Mar 2026 00:56:48 +0100 Subject: [PATCH 49/51] Fix: Restore utils.toPositiveNumber function The function was accidentally removed but is still used in PublishTaskImageProcessing.lua (6 calls for resize validation). Co-Authored-By: Claude Haiku 4.5 --- piwigoPublish.lrplugin/utils.lua | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/piwigoPublish.lrplugin/utils.lua b/piwigoPublish.lrplugin/utils.lua index 1d5ca02..fdffc12 100644 --- a/piwigoPublish.lrplugin/utils.lua +++ b/piwigoPublish.lrplugin/utils.lua @@ -72,6 +72,16 @@ function utils.extractNumber(inStr) return nil -- no number found end +-- ************************************************* +-- Convert input value to a positive number; return nil if missing/invalid/non-positive. +function utils.toPositiveNumber(value) + local n = tonumber(value) + if n and n > 0 then + return n + end + return nil +end + -- ************************************************* function utils.dmsToDecimal(deg, min, sec, hemi) -- convert DMS (degrees, minutes, seconds + direction) to decimal degrees From 1aa717b9facb8e0586238a59af1e07a094963da3 Mon Sep 17 00:00:00 2001 From: Gotcha26 Date: Fri, 6 Mar 2026 13:35:06 +0100 Subject: [PATCH 50/51] Fix: Restore log anonymisation + improve keyword filter UX message - utils.lua: Restore utils.anonymisePropertyTable() (removed by refactor) - PiwigoAPI.lua: Re-apply anonymisePropertyTable() on all propertyTable/ publishSettings log calls (deletePhoto x2, addComment x3) - PublishTask.lua: Change keyword block message from 'Blocked by' to 'Skipped (keyword filter):' to avoid confusion with upload errors Co-Authored-By: Claude Haiku 4.5 --- piwigoPublish.lrplugin/PiwigoAPI.lua | 10 ++--- piwigoPublish.lrplugin/PublishTask.lua | 2 +- piwigoPublish.lrplugin/utils.lua | 53 ++++++++++++++++++++++++++ 3 files changed, 59 insertions(+), 6 deletions(-) diff --git a/piwigoPublish.lrplugin/PiwigoAPI.lua b/piwigoPublish.lrplugin/PiwigoAPI.lua index 115bd2b..47169a3 100644 --- a/piwigoPublish.lrplugin/PiwigoAPI.lua +++ b/piwigoPublish.lrplugin/PiwigoAPI.lua @@ -2667,7 +2667,7 @@ function PiwigoAPI.deletePhoto(propertyTable, pwCatID, pwImageID, callStatus) callStatus.status = true callStatus.statusMsg = "" else - log:info("PiwigoAPI.deletePhoto - propertyTable \n " .. utils.serialiseVar(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)) @@ -2675,7 +2675,7 @@ function PiwigoAPI.deletePhoto(propertyTable, pwCatID, pwImageID, callStatus) callStatus.statusMsg = (body and body.message) or "" end else - log:info("PiwigoAPI.deletePhoto - propertyTable \n " .. utils.serialiseVar(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)) @@ -2779,7 +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(publishSettings)) + log:info("PiwigoAPI.addComment - unanble to retrieve token\n" .. utils.serialiseVar(utils.anonymisePropertyTable(publishSettings))) return false end local imageDets = rtnStatus.imageDets @@ -2802,11 +2802,11 @@ function PiwigoAPI.addComment(publishSettings, metaData) return false end if utils.nilOrEmpty(author) then - log:info("PiwigoAPI.addComment - missing author\n" .. utils.serialiseVar(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(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 diff --git a/piwigoPublish.lrplugin/PublishTask.lua b/piwigoPublish.lrplugin/PublishTask.lua index 520c4a4..d0c6ff3 100644 --- a/piwigoPublish.lrplugin/PublishTask.lua +++ b/piwigoPublish.lrplugin/PublishTask.lua @@ -415,7 +415,7 @@ function PublishTask.processRenderedPhotos(functionContext, exportContext) if bSuccess and LrFileUtils.exists(bPath) then LrFileUtils.delete(bPath) end - rendition:uploadFailed("Blocked by keyword filter: " .. reason) + rendition:uploadFailed("Skipped (keyword filter): " .. reason) kwBlocked = true end end diff --git a/piwigoPublish.lrplugin/utils.lua b/piwigoPublish.lrplugin/utils.lua index fdffc12..1c37c39 100644 --- a/piwigoPublish.lrplugin/utils.lua +++ b/piwigoPublish.lrplugin/utils.lua @@ -61,6 +61,59 @@ function utils.uuid() end) end +-- ************************************************* +function utils.anonymisePropertyTable(propertyTable) + -- return copy of property table with sensitive data removed (for logging etc) + if type(propertyTable) ~= "table" then + return propertyTable + end + + local redactedKeys = { + userpw = true, + username = true, + host = true, + pwurl = true, + cookieheader = true, + sessioncookie = true, + token = true, + cookies = true, + } + + local droppedKeys = { + tagtable = true, + _tagindex = true, + } + + local visited = {} + + local function anonymiseValue(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 + local keyName = type(k) == "string" and string.lower(k) or nil + if keyName and droppedKeys[keyName] then + copy[k] = nil + elseif keyName and redactedKeys[keyName] then + copy[k] = "****" + else + copy[k] = anonymiseValue(v) + end + end + + return copy + end + + return anonymiseValue(propertyTable) +end + -- ************************************************* function utils.extractNumber(inStr) -- Extract first number (integer or decimal, optional sign) from a string From 81c577ff0cc69404e93b2a5396bf90d9856850f5 Mon Sep 17 00:00:00 2001 From: Gotcha26 Date: Fri, 6 Mar 2026 14:36:21 +0100 Subject: [PATCH 51/51] Simplify collection UI: remove unused sync override parameters - Remove 'Refresh from Piwigo' button (not functional in edit context) - Simplify createPiwigoAlbumSettingsUI() signature (no remoteId/publishSettings) - Remove per-collection sync overrides from defaults - Keep global albumDescSyncMode/albumStatusSyncMode for conflict resolution - Update buildCommonCollectionUI() calls in both viewForCollectionSettings This reverts the incomplete per-collection override feature. Album metadata sync now relies on global settings + reconcile dialog during publish. --- piwigoPublish.lrplugin/PublishTask.lua | 8 ++-- piwigoPublish.lrplugin/UIHelpers.lua | 64 ++++++++++++-------------- 2 files changed, 33 insertions(+), 39 deletions(-) diff --git a/piwigoPublish.lrplugin/PublishTask.lua b/piwigoPublish.lrplugin/PublishTask.lua index d0c6ff3..6242c6f 100644 --- a/piwigoPublish.lrplugin/PublishTask.lua +++ b/piwigoPublish.lrplugin/PublishTask.lua @@ -1046,8 +1046,8 @@ local function initCollectionSettingsDefaults(collectionSettings) end end -local function buildCommonCollectionUI(f, bind, share, collectionSettings, publishSettings) - local pwAlbumUI = UIHelpers.createPiwigoAlbumSettingsUI(f, share, 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", @@ -1111,7 +1111,7 @@ function PublishTask.viewForCollectionSettings(f, publishSettings, info) { title = "All Except Camera & Camera Raw Info", value = "All Except Camera & Camera Raw Info" }, } - local pwAlbumUI, sortOrderUI, kwFilterUI = buildCommonCollectionUI(f, bind, share, collectionSettings, publishSettings) + local pwAlbumUI, sortOrderUI, kwFilterUI = buildCommonCollectionUI(f, bind, share, collectionSettings) local pubSettingsUI = f:group_box { title = "Custom Publish Settings (Overrides defaults set in Publish Settings)", @@ -1341,7 +1341,7 @@ function PublishTask.viewForCollectionSetSettings(f, publishSettings, info) initCollectionSettingsDefaults(collectionSettings) - local pwAlbumUI, sortOrderUI, kwFilterUI = buildCommonCollectionUI(f, bind, share, collectionSettings, publishSettings) + local pwAlbumUI, sortOrderUI, kwFilterUI = buildCommonCollectionUI(f, bind, share, collectionSettings) local UI = f:column { spacing = f:control_spacing(), diff --git a/piwigoPublish.lrplugin/UIHelpers.lua b/piwigoPublish.lrplugin/UIHelpers.lua index 6a46b8d..1521e17 100644 --- a/piwigoPublish.lrplugin/UIHelpers.lua +++ b/piwigoPublish.lrplugin/UIHelpers.lua @@ -57,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 = "", @@ -66,40 +93,7 @@ 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 = 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 = "", - } - } + unpack(rows), } } end