--[[ Export → Kepla Transfer. What happens on Export: 1. Lightroom renders the JPEGs to a temp folder (the usual export settings: size, quality, sharpening; the location section is hidden). 2. The files are packed into one stored ZIP (the fast path shells out to `zip -0` on macOS or bsdtar on Windows; the Lua writer is the fallback and the way the small preview pack is always made). 3. The transfer is created on getkepla.com, the archive goes up straight to storage (one request, or 32 MB parts with checksums), the preview pack (tiles and screen renditions from Lightroom's previews) goes up beside it. 4. `finish` carries the details; the link lands on the clipboard and in a dialog with Open. A first send from a new address holds the link until the mail's confirm is clicked, exactly as on the web. Limits: 3,000 files and 10 GB a transfer as on the web, and under 4 GB from here (no zip64 in this writer). Videos are not exported by this plugin. Validation happens in the dialog (Export stays disabled with the reason), with the same rules the server applies, so nothing is uploaded and then refused. ]] local LrView = import 'LrView' local LrDialogs = import 'LrDialogs' local LrPathUtils = import 'LrPathUtils' local LrFileUtils = import 'LrFileUtils' local LrTasks = import 'LrTasks' local LrDigest = import 'LrDigest' local LrHttp = import 'LrHttp' local LrPrefs = import 'LrPrefs' local LrColor = import 'LrColor' local Json = require 'Json' local Zip = require 'Zip' local API = require 'KeplaAPI' local bind = LrView.bind -- Lightroom's network calls, sleeps and dialogs yield the task, and Lua 5.1 -- cannot yield across a plain pcall (a C boundary: "Yielding is not allowed -- within a C or metamethod call"), so every protected call around them is -- LrTasks.pcall, which the SDK provides for exactly this. local prefs = LrPrefs.prefsForPlugin() local provider = {} provider.hideSections = { 'exportLocation', 'video' } provider.allowFileFormats = { 'JPEG' } provider.allowColorSpaces = { 'sRGB' } provider.canExportVideo = false provider.hidePrintResolution = true provider.exportPresetFields = { { key = 'senderEmail', default = '' }, { key = 'senderName', default = '' }, { key = 'recipients', default = '' }, { key = 'title', default = '' }, { key = 'message', default = '' }, { key = 'expiryHours', default = 72 }, { key = 'pin', default = '' }, { key = 'slug', default = '' }, { key = 'previews', default = true }, { key = 'copyLink', default = true }, } --- the same ceilings the web page keeps, so a pack is never built and then refused provider.limits = { files = 3000, -- a transfer bytes = 4 * 1024 * 1024 * 1024 - 1048576, -- the writer has no zip64 packCeil = 250 * 1024 * 1024, -- thumbs-done refuses past 256 MB viewBudget = 170 * 1024 * 1024, -- screen renditions stop here, tiles go on recipients = 10, pinMin = 4, pinMax = 64, } local EXPIRY = { { title = '1 hour', value = 1 }, { title = '6 hours', value = 6 }, { title = '24 hours', value = 24 }, { title = '3 days', value = 72 }, { title = '2 weeks', value = 336 } } local EMAIL = '^[^%s@]+@[^%s@]+%.[^%s@]+$' local GRAY = LrColor(0.45, 0.45, 0.45) -- ---- small helpers ---- local function trim(s) return (tostring(s or ''):gsub('^%s+', ''):gsub('%s+$', '')) end local function chars(s) return #(tostring(s or ''):gsub('[\128-\191]', '')) end -- characters, not bytes · the server counts characters local function isAscii(s) return not s:find('[\128-\255]') end --- every address in the box, and whether any of them is wrong local function recipientsOf(s) local out, bad = {}, nil for part in tostring(s or ''):gmatch('[^,;%s]+') do if part:match(EMAIL) then out[#out + 1] = part:lower() else bad = part end end return out, bad end --- the link's name the way the server cleans it (cleanSlug): lower, dashes, 3 to 40, a letter in it local function slugOf(v) local x = tostring(v or ''):lower():gsub('&', ' and '):gsub('[^a-z0-9]+', '-'):gsub('%-+', '-'):gsub('^%-', ''):gsub('%-$', '') if #x >= 3 and #x <= 40 and x:find('%a') then return x end return nil end local function readAll(path) local f = assert(io.open(path, 'rb')); local d = f:read('*a'); f:close(); return d end local function sizeOf(path) local a = LrFileUtils.fileAttributes(path); return a and a.fileSize or 0 end local function urlPath(name) -- the X-Bz-File-Name value: each segment escaped return (name:gsub('[^/]+', function (seg) return (seg:gsub('[^%w%-%._~]', function (c) return string.format('%%%02X', c:byte()) end)) end)) end local function sq(s) return "'" .. s:gsub("'", "'\\''") .. "'" end local function clipboard(text, temp) if WIN_ENV then local p = LrPathUtils.child(temp, 'kepla-link.txt') local f = io.open(p, 'wb'); if not f then return false end; f:write(text); f:close() return LrTasks.execute('cmd /c type "' .. p .. '" | clip') == 0 end return LrTasks.execute('printf %s ' .. sq(text) .. ' | pbcopy') == 0 end --- a JPEG preview from Lightroom's own previews · the request object must stay --- referenced until the callback has fired, or it is collected and never fires local function previewJpeg(photo, w, h) local got = nil local req = photo:requestJpegThumbnail(w, h, function (data, err) got = { data = data, err = err } end) local waited = 0 while not got and waited < 30 do LrTasks.sleep(0.05); waited = waited + 0.05 end req = nil return got and got.data or nil end -- ---- the dialog ---- --- what stops the export, in one line · nil when nothing does local function problem(P) local email = trim(P.senderEmail):lower() if email == '' then return 'Your email is needed. It is where the link and the receipt go.' end if not email:match(EMAIL) then return 'That email does not look right.' end local rcpts, bad = recipientsOf(P.recipients) if bad then return 'This recipient address does not look right: ' .. bad end if #rcpts > provider.limits.recipients then return 'Up to ' .. provider.limits.recipients .. ' recipients.' end local pin = trim(P.pin) if pin ~= '' and (chars(pin) < provider.limits.pinMin or chars(pin) > provider.limits.pinMax) then return 'A password is 4 to 64 characters, or leave it empty.' end local slug = trim(P.slug) if slug ~= '' and not slugOf(slug) then return 'A link name is 3 to 40 letters, digits and dashes, with at least one letter.' end return nil end local function revalidate(P) local why = problem(P) P.LR_canExport = why == nil P.LR_cantExportBecause = why end function provider.startDialog(P) if (P.senderEmail or '') == '' and prefs.senderEmail then P.senderEmail = prefs.senderEmail end if (P.senderName or '') == '' and prefs.senderName then P.senderName = prefs.senderName end for _, k in ipairs({ 'senderEmail', 'recipients', 'pin', 'slug' }) do P:addObserver(k, function () revalidate(P) end) end revalidate(P) end function provider.updateExportSettings(settings) settings.LR_format = 'JPEG' settings.LR_export_colorSpace = 'sRGB' end --- what the export will do, in one line, live local function summaryOf(v) local rcpts = recipientsOf(v.recipients) local hours = tonumber(v.expiryHours) or 72 local lives = hours == 1 and 'an hour' or hours == 6 and '6 hours' or hours == 24 and 'a day' or hours == 72 and '3 days' or '2 weeks' local who = #rcpts == 0 and 'a link for you to paste' or ('sent to ' .. #rcpts .. (#rcpts == 1 and ' person' or ' people') .. ' by email') local slug = slugOf(trim(v.slug)) local out = 'A gallery, ' .. who .. ', live for ' .. lives if slug then out = out .. ', at getkepla.com/t/' .. slug end if trim(v.pin) ~= '' then out = out .. ', with a password' end return out .. '.' end function provider.sectionsForTopOfDialog(f, P) local mark = LrPathUtils.child(_PLUGIN.path, 'kepla-mark.png') local LW = 86 -- label column return { { title = 'Kepla Transfer', synopsis = bind { keys = { 'senderEmail', 'recipients', 'expiryHours', 'slug', 'pin' }, operation = function (_, v) return (trim(v.senderEmail) == '' and 'Your email is needed' or summaryOf(v)) end }, f:row { spacing = f:control_spacing(), f:picture { value = mark }, f:column { spacing = 2, f:static_text { title = 'Export straight to a gallery link', font = '' }, f:static_text { title = 'Free, no account. Photos open as a gallery with favorites. The link expires when you say and the files are deleted.', text_color = GRAY, width = 400, height_in_lines = 2 }, }, }, f:spacer { height = 6 }, f:row { f:static_text { title = 'From', width = LW, alignment = 'right' }, f:edit_field { value = bind 'senderEmail', width_in_chars = 21, immediate = true, placeholder_string = 'you@studio.com' }, f:edit_field { value = bind 'senderName', width_in_chars = 16, placeholder_string = 'Your name or studio' }, }, f:row { f:static_text { title = '', width = LW }, f:static_text { title = 'The link and the receipt go to this address. The name goes on the email and the gallery.', text_color = GRAY, width = 400, height_in_lines = 2 }, }, f:row { f:static_text { title = 'Send to', width = LW, alignment = 'right' }, f:edit_field { value = bind 'recipients', width_in_chars = 40, immediate = true, placeholder_string = 'client@gmail.com, another@… or empty for a link to paste' }, }, f:row { f:static_text { title = 'Title', width = LW, alignment = 'right' }, f:edit_field { value = bind 'title', width_in_chars = 40, placeholder_string = 'Sarah & Tom' }, }, f:row { f:static_text { title = 'Message', width = LW, alignment = 'right' }, f:edit_field { value = bind 'message', width_in_chars = 40, height_in_lines = 2, placeholder_string = 'Here are the finals, hope you love them.' }, }, f:spacer { height = 4 }, f:row { f:static_text { title = 'Link lives', width = LW, alignment = 'right' }, f:popup_menu { value = bind 'expiryHours', items = EXPIRY, width_in_chars = 10 }, f:static_text { title = 'Then the files are deleted.', text_color = GRAY }, }, f:row { f:static_text { title = 'Link name', width = LW, alignment = 'right' }, f:edit_field { value = bind 'slug', width_in_chars = 20, immediate = true, placeholder_string = 'sarah-and-tom (optional)' }, f:static_text { title = bind { key = 'slug', transform = function (v) local x = slugOf(trim(v)); return x and ('getkepla.com/t/' .. x) or 'getkepla.com/t/…' end }, text_color = GRAY, width = 200 }, }, f:row { f:static_text { title = 'Password', width = LW, alignment = 'right' }, f:edit_field { value = bind 'pin', width_in_chars = 20, immediate = true, placeholder_string = 'optional, 4 to 64 characters' }, f:static_text { title = 'Printed in their email.', text_color = GRAY }, }, f:spacer { height = 4 }, f:row { f:static_text { title = '', width = LW }, f:checkbox { title = 'Build the gallery previews here', value = bind 'previews' }, f:static_text { title = 'a faster gallery for them', text_color = GRAY }, }, f:row { f:static_text { title = '', width = LW }, f:checkbox { title = 'Copy the link to the clipboard when done', value = bind 'copyLink' }, }, f:spacer { height = 6 }, f:row { f:static_text { title = '', width = LW }, f:static_text { title = bind { keys = { 'recipients', 'expiryHours', 'slug', 'pin' }, operation = function (_, v) return summaryOf(v) end }, font = '', width = 400, height_in_lines = 2 }, }, f:row { f:static_text { title = '', width = LW }, f:static_text { title = 'Below: JPEG and sRGB are set for you. Image Sizing off and Quality 85 to 90 is what most clients want. Up to 4 GB a transfer from here.', text_color = GRAY, width = 400, height_in_lines = 3 }, }, }, } end -- ---- the archive ---- local function canceled(progress) if progress:isCanceled() then error({ kepla = true, message = 'canceled' }, 0) end end --- the system tool when it is there and the names are plain ASCII (Info-ZIP --- does not flag UTF-8 names, which a Windows unzip then garbles), the Lua writer --- otherwise · streamed, so a 2 GB export never sits in memory whole local function buildArchive(files, outPath, temp, progress) LrFileUtils.delete(outPath) local dir = LrPathUtils.parent(files[1].path) local plain = true for _, f in ipairs(files) do if not isAscii(f.name) then plain = false end end local ok = false if plain and not WIN_ENV then local names = {} for _, f in ipairs(files) do names[#names + 1] = sq(f.name) end ok = LrTasks.execute('cd ' .. sq(dir) .. ' && zip -0 -q -X ' .. sq(outPath) .. ' ' .. table.concat(names, ' ')) == 0 elseif plain then -- the names go through a list file: cmd.exe stops at 8,191 characters on the line local list = LrPathUtils.child(temp, 'names.txt') local lf = io.open(list, 'wb') if lf then for _, f in ipairs(files) do lf:write(f.name, '\n') end lf:close() ok = LrTasks.execute('cmd /c tar -a -cf "' .. outPath .. '" --options zip:compression=store -C "' .. dir .. '" -T "' .. list .. '"') == 0 end end if ok and sizeOf(outPath) > 0 then canceled(progress); return 'tool' end local out = assert(io.open(outPath, 'wb')) local okAll, err = LrTasks.pcall(function () local z = Zip.new(function (s) out:write(s) end) for i, f in ipairs(files) do progress:setCaption('Packing ' .. f.name .. ' (' .. i .. ' of ' .. #files .. ')') z:addStreamed(f.name, f.size, function () return assert(io.open(f.path, 'rb')) end) canceled(progress) end z:finish() end) out:close() if not okAll then error(err, 0) end return 'lua' end -- ---- the preview pack ---- --- tiles (480) and screen renditions (1600) from the catalog's previews, laid out --- the way the gallery reads them: the views first, then every tile contiguous at --- the tail (the wall is one byte range), index.json last. Views stop at the --- budget and the whole pack stops at the ceiling; tiles beyond that are dropped --- and the gallery reads those originals instead. local function buildPack(files, title, packPath, tilesPath, progress) local L = provider.limits local out = assert(io.open(packPath, 'wb')) local tf = assert(io.open(tilesPath, 'w+b')) local okAll, res = LrTasks.pcall(function () local z = Zip.new(function (s) out:write(s) end) local items, tiles, viewBytes, tileBytes, tileOff = {}, {}, 0, 0, 0 for i, f in ipairs(files) do progress:setCaption('Previews ' .. i .. ' of ' .. #files) local tile = previewJpeg(f.photo, 480, 480) if tile and z.pos + viewBytes + tileBytes + #tile + 4096 < L.packCeil then local n = #tiles local tn, vn = string.format('s/%05d.jpg', n), string.format('v/%05d.jpg', n) tf:write(tile) tiles[#tiles + 1] = { name = tn, size = #tile, crc = Zip.crc32(tile), off = tileOff } tileOff = tileOff + #tile; tileBytes = tileBytes + #tile local view = nil if viewBytes < L.viewBudget then view = previewJpeg(f.photo, 1600, 1600) if view and z.pos + #view + tileBytes + 4096 < L.packCeil then z:add(vn, view); viewBytes = viewBytes + #view else view = nil end end local dims = f.photo:getRawMetadata('croppedDimensions') or {} local ow, oh = tonumber(dims.width) or 3, tonumber(dims.height) or 2 local ar = ow / oh items[#items + 1] = { n = f.name, t = tn, f = view and vn or nil, w = ar >= 1 and 480 or math.floor(480 * ar), h = ar >= 1 and math.floor(480 / ar) or 480, ow = ow, oh = oh, s = f.size } end canceled(progress) end for _, t in ipairs(tiles) do z:addFrom(t.name, t.size, t.crc, tf, t.off) end z:add('index.json', Json.encode({ v = 3, title = title ~= '' and title or Json.null, items = items, videos = {} })) return { size = z:finish(), made = #tiles } end) out:close(); tf:close() if not okAll then error(res, 0) end return res.size, res.made end -- ---- the export ---- function provider.processRenderedPhotos(functionContext, exportContext) local P = exportContext.propertyTable local L = provider.limits local why = problem(P) if why then LrDialogs.message('Kepla Transfer', why, 'critical'); return end local email, name = trim(P.senderEmail):lower(), trim(P.senderName) local rcpts = recipientsOf(P.recipients) local pin, slug, title = trim(P.pin), slugOf(trim(P.slug)), trim(P.title) prefs.senderEmail = email; prefs.senderName = name local session = exportContext.exportSession local n = session:countRenditions() local progress = exportContext:configureProgress { title = 'Kepla Transfer: ' .. n .. (n == 1 and ' photo' or ' photos') } local temp = LrPathUtils.child(LrPathUtils.getStandardFilePath('temp'), 'kepla-transfer-' .. os.time() .. '-' .. math.random(1000, 9999)) LrFileUtils.createAllDirectories(temp) -- whatever happens, the temp folder goes (Lightroom runs cleanup handlers on error too) functionContext:addCleanupHandler(function () LrFileUtils.delete(temp) end) local files, total, failed = {}, 0, 0 local okAll, err = LrTasks.pcall(function () -- 1 · render (Lightroom drives the first quarter of the bar) for i, rendition in exportContext:renditions { stopIfCanceled = true, renderProgressPortion = 0.25 } do local success, pathOrMessage = rendition:waitForRender() canceled(progress) if success then local base = LrPathUtils.leafName(pathOrMessage) local dest = LrPathUtils.child(temp, base) -- two photos may render to the same name · keep both local k = 1 while LrFileUtils.exists(dest) do k = k + 1; dest = LrPathUtils.child(temp, LrPathUtils.removeExtension(base) .. '-' .. k .. '.' .. LrPathUtils.extension(base)) end LrFileUtils.move(pathOrMessage, dest) local size = sizeOf(dest) files[#files + 1] = { path = dest, name = LrPathUtils.leafName(dest), size = size, photo = rendition.photo } total = total + size else failed = failed + 1 end end canceled(progress) -- a cancel between renditions ends the loop quietly · never send a partial set if #files == 0 then error({ kepla = true, message = 'Nothing was exported.' .. (failed > 0 and (' ' .. failed .. ' photo' .. (failed == 1 and '' or 's') .. ' could not be rendered.') or '') }, 0) end if #files > L.files then error({ kepla = true, message = 'Up to 3,000 files a transfer.' }, 0) end if total > L.bytes then error({ kepla = true, message = 'Up to 4 GB from Lightroom. Split the export, or send from getkepla.com/transfer (10 GB).' }, 0) end -- 2 · pack progress:setCaption('Packing…') local zipPath = LrPathUtils.child(temp, 'archive.zip') buildArchive(files, zipPath, temp, progress) local zipBytes = sizeOf(zipPath) progress:setPortionComplete(0.35, 1) -- 3 · create and upload local names = {} for _, f in ipairs(files) do names[#names + 1] = f.name end local created = API.call('create', { senderEmail = email, senderName = name ~= '' and name or nil, title = title ~= '' and title or nil, expiryHours = tonumber(P.expiryHours) or 72, fileCount = #files, totalBytes = total, zipBytes = zipBytes, src = { first = { at = os.time() * 1000, ref = '', q = { utm_source = 'lightroom' }, path = '/lightroom' } } }) local code, key = created.code, created.key local sha1s = {} local zf = assert(io.open(zipPath, 'rb')) local okUp, errUp = LrTasks.pcall(function () if created.mode == 'small' then progress:setCaption('Uploading…') local bytes = zf:read('*a') local u = API.call('upload-url', { code = code, key = key }) API.put(u.uploadUrl, bytes, { { field = 'Authorization', value = u.token }, { field = 'X-Bz-File-Name', value = urlPath(u.fileName) }, { field = 'X-Bz-Content-Sha1', value = LrDigest.SHA1.digest(bytes) }, { field = 'Content-Type', value = 'application/zip' } }, function () return API.call('upload-url', { code = code, key = key }) end) else local partSize = tonumber(created.partSize) or (32 * 1024 * 1024) local parts, sent, k = math.ceil(zipBytes / partSize), 0, 0 while true do local chunk = zf:read(partSize) if not chunk then break end k = k + 1 progress:setCaption('Uploading part ' .. k .. ' of ' .. parts) local sha = LrDigest.SHA1.digest(chunk) local u = API.call('part-url', { code = code, key = key }) API.put(u.uploadUrl, chunk, { { field = 'Authorization', value = u.token }, { field = 'X-Bz-Part-Number', value = tostring(k) }, { field = 'X-Bz-Content-Sha1', value = sha } }, function () return API.call('part-url', { code = code, key = key }) end) sha1s[#sha1s + 1] = sha sent = sent + #chunk progress:setPortionComplete(0.35 + (sent / zipBytes) * 0.45, 1) canceled(progress) end end end) zf:close() if not okUp then error(errUp, 0) end progress:setPortionComplete(0.8, 1) -- 4 · the preview pack if P.previews then local packPath, tilesPath = LrPathUtils.child(temp, 'pack.zip'), LrPathUtils.child(temp, 'tiles.bin') local okPack, packErr = LrTasks.pcall(function () local size, made = buildPack(files, title, packPath, tilesPath, progress) if made > 0 then local bytes = readAll(packPath) local u = API.call('thumb-url', { code = code, key = key }) API.put(u.uploadUrl, bytes, { { field = 'Authorization', value = u.token }, { field = 'X-Bz-File-Name', value = urlPath(u.fileName) }, { field = 'X-Bz-Content-Sha1', value = LrDigest.SHA1.digest(bytes) }, { field = 'Content-Type', value = 'application/zip' } }, function () return API.call('thumb-url', { code = code, key = key }) end) API.call('thumbs-done', { code = code, key = key, bytes = size, photos = made }) end end) if not okPack and API.message(packErr) == 'canceled' then error(packErr, 0) end -- any other preview failure is not fatal · the gallery reads the originals then end progress:setPortionComplete(0.9, 1) -- 5 · finish progress:setCaption('Finishing…') local body = { code = code, key = key, senderEmail = email, senderName = name ~= '' and name or nil, recipientEmails = rcpts, title = title ~= '' and title or nil, message = trim(P.message) ~= '' and trim(P.message) or nil, expiryHours = tonumber(P.expiryHours) or 72, fileCount = #files, totalBytes = total, names = names, pin = pin ~= '' and pin or nil, slug = slug } if #sha1s > 0 then body.sha1s = sha1s end local fin = API.call('finish', body) progress:setPortionComplete(1, 1) -- 6 · the link local url = fin.url or '' local copied = P.copyLink and clipboard(url, temp) local notes = '' if fin.slugTaken then notes = notes .. '\n\nThe link name you wanted was taken, so the link uses its code.' end if fin.pin then notes = notes .. '\n\nPassword: ' .. fin.pin end if failed > 0 then notes = notes .. '\n\n' .. failed .. ' photo' .. (failed == 1 and '' or 's') .. ' could not be rendered and ' .. (failed == 1 and 'is' or 'are') .. ' not in the transfer.' end if copied then notes = notes .. '\n\nThe link is on your clipboard.' end if fin.pending then LrDialogs.message('One more step', 'This is the first send from ' .. (fin.verifyEmail or email) .. '. We mailed a confirm link there; the transfer goes out the moment it is clicked. The link will be:\n\n' .. url .. notes, 'info') else local choice = LrDialogs.confirm('Sent' .. (#rcpts > 0 and (' to ' .. #rcpts .. (#rcpts == 1 and ' person' or ' people')) or ''), url .. notes, 'Open', 'Done') if choice == 'ok' then LrHttp.openUrlInBrowser(url) end end end) LrFileUtils.delete(temp) if not okAll then local msg = API.message(err) if msg ~= 'canceled' then LrDialogs.message('Kepla Transfer', msg, 'critical') end end end return provider