<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0"><channel><title><![CDATA[Building WedSchedulePro]]></title><description><![CDATA[Building WedSchedulePro]]></description><link>https://wedschedulepro.hashnode.dev</link><generator>RSS for Node</generator><lastBuildDate>Thu, 12 Nov 2026 09:22:37 GMT</lastBuildDate><atom:link href="https://wedschedulepro.hashnode.dev/rss.xml" rel="self" type="application/rss+xml"/><language><![CDATA[en]]></language><ttl>60</ttl><item><title><![CDATA[What we learned building a Lightroom Classic Publish Service in Lua 5.1]]></title><description><![CDATA[Lessons from building a Lightroom Classic plug-in: Lua 5.1 limits, import vs require, LrHttp in async tasks, and never failing a whole batch.]]></description><link>https://wedschedulepro.hashnode.dev/what-we-learned-building-a-lightroom-classic-publish-service-in-lua-5-1</link><guid isPermaLink="true">https://wedschedulepro.hashnode.dev/what-we-learned-building-a-lightroom-classic-publish-service-in-lua-5-1</guid><category><![CDATA[Lua]]></category><category><![CDATA[lightroom]]></category><category><![CDATA[Photography]]></category><category><![CDATA[plugins]]></category><dc:creator><![CDATA[Simon Bradley]]></dc:creator><pubDate>Mon, 09 Nov 2026 17:10:53 GMT</pubDate><content:encoded><![CDATA[<p>I'm a working photographer, and for years my best work sat in Lightroom. There was no obvious way to get it out to social media without hours of exporting, transferring and re-uploading. I wanted something simple: get the photos out of Lightroom and into the cloud, ready to post.</p>
<p>We build <a href="https://wedschedule.pro/lightroom">WedSchedulePro</a>, a social media scheduler for photographers and wedding suppliers. The part people ask about most is the Lightroom Classic plug-in: you publish a collection in Lightroom and the photos land in your library, ready to caption and schedule. We're now past version 1.80 of it. These are the lessons that cost us the most, so they might save you some.</p>
<h2>Why a Publish Service, not an export preset</h2>
<p>An export preset writes files to a folder. A <strong>Publish Service</strong> is a destination that remembers what it has sent. Lightroom tracks every photo in a published collection: new, modified since publish, or published. Edit a photo after sending it and Lightroom marks it for re-publish by itself.</p>
<p>For a photographer, that's the job after the gallery is delivered. You already have a collection of the best shots. Publishing it should be the last click.</p>
<h2>Lua 5.1, and not quite the Lua 5.1 you know</h2>
<p>Lightroom Classic runs plug-ins in <strong>Lua 5.1.4</strong>. No <code>//</code>, no bitwise operators, no <code>table.unpack</code> (it's the global <code>unpack</code>), no <code>goto</code>. And Adobe's SDK guide says plainly that Lightroom does <strong>not</strong> support the module system Lua 5.1 introduced: there is no <code>package</code> table.</p>
<p>That has three consequences we learned the hard way.</p>
<p><strong>1. <code>import</code> for Adobe's modules, <code>require</code> for yours.</strong> Lightroom's own namespaces are injected through <code>import</code>:</p>
<pre><code class="language-lua">local LrHttp  = import 'LrHttp'
local LrTasks = import 'LrTasks'
local json    = require 'json'   -- our own file, at the plug-in root
</code></pre>
<p><code>require 'LrHttp'</code> fails with <em>"Could not load toolkit script"</em>, because it looks for a file that isn't there.</p>
<p><strong>2. Keep every module flat at the plug-in root.</strong> <code>require 'lib.retry'</code> looks reasonable and passes every desktop test, because desktop Lua resolves dotted names through <code>package.path</code>. Lightroom has no <code>package.path</code>. It looks in the plug-in root only, by bare name. We shipped the dotted version once. Every test was green, and the plug-in didn't load.</p>
<p><strong>3. Your test runner isn't Lightroom.</strong> Stock Lua 5.1 is hard to install now, and the <code>lua</code> on most machines is 5.4, which happily accepts syntax Lightroom rejects. We run our pure-Lua tests under LuaJIT (it reports itself as Lua 5.1). Even LuaJIT accepts <code>goto</code> and bitwise ops as its own extensions, so a small linter in our pre-push hook blocks those too. Green tests prove the logic. Only a load inside Lightroom proves the plug-in loads.</p>
<h2><code>Info.lua</code> is not a normal Lua file</h2>
<p>The descriptor is read in a restricted environment: no <code>import</code>, no <code>require</code>, only <code>string</code>, <code>LOC</code> and a few globals. It declares things. It doesn't run them.</p>
<p>That includes the publish service itself. You don't "register" a provider at runtime. You declare it:</p>
<pre><code class="language-lua">LrExportServiceProvider = {
  title = "WedSchedulePro",
  file  = "WSPPublishServiceProvider.lua",
},
</code></pre>
<p>One key we got wrong: the OAuth callback handler is declared as <code>URLHandler</code>, with no <code>Lr</code> prefix. Ours said <code>LrUrlHandler</code>, so the handler never registered. Nobody noticed for a while, because our paste-the-code fallback hid it at the time.</p>
<h2>Talking to a web API from inside Lightroom</h2>
<p><strong>Sign-in.</strong> We use OAuth with a browser hand-off and a <code>lightroom://</code> redirect back into the plug-in. That redirect can be flaky, so we shipped a <strong>paste-the-code</strong> dialog as a backup from day one. Once we fixed the two real causes of the flaky redirect, we retired it. The plug-in now says plainly when the browser hand-off hasn't come back, and offers to reopen it. Have a backup while you find out why the redirect fails.</p>
<p><strong><code>LrHttp</code> must run inside a task, and it yields.</strong> Lua 5.1 can't yield across a C-call boundary. Wrap an <code>LrHttp</code> call in a plain <code>pcall</code> and you get <em>"Yielding is not allowed within a C or metamethod call"</em>. Inside an async task, use the yield-safe version:</p>
<pre><code class="language-lua">LrTasks.startAsyncTask(function()
  local ok, body, headers = LrTasks.pcall(LrHttp.get, url, requestHeaders)
  -- ...
end)
</code></pre>
<p>We have fixed this class of bug in more than one release, each time sure we had found the last one. Our rule now is to re-check every <code>pcall</code> on every pass, and to judge each by what it wraps.</p>
<p><strong>Read failures the way <code>LrHttp</code> reports them.</strong> A network failure doesn't throw. You get a <code>nil</code> body and an info table describing the error. A server error comes back as a normal body with <code>headers.status</code> set. Those are two different branches, and you want both.</p>
<p><strong>It follows redirects and caches GETs.</strong> You can't read a 302's <code>Location</code>, so don't build sign-in on catching one. And a status poll that never changes is probably being served from cache: send <code>Cache-Control: no-cache</code>.</p>
<p><strong>Guard every file before <code>postMultipart</code>.</strong> If a rendition's temp file has gone, <code>postMultipart</code> crashes while computing the content length, and you can't catch it. Check the file exists, is readable and has a size before building the request.</p>
<h2>One photo at a time, and never fail the whole batch</h2>
<p>The render-and-upload loop handles one photo at a time behind Lightroom's own progress bar, so cancel works and nothing is left half-sent. Two rules made it dependable:</p>
<ul>
<li><strong>Record what you uploaded.</strong> Every success calls <code>rendition:recordPublishedPhotoId(id)</code>. Skip it and Lightroom re-uploads that photo every time you publish.</li>
<li><strong>A failed render fails one photo.</strong> <code>rendition:waitForRender()</code> can fail in Publish where Export works. Mark that photo failed, carry on with the rest, and say clearly at the end which ones didn't go.</li>
</ul>
<h2>What a Publish Service can't do</h2>
<p>Publish Services can't export video. We don't treat that as an error: the plug-in sets videos aside before rendering, finishes the publish, and names each skipped file in the end-of-publish summary.</p>
<h2>Small things that cost us a day each</h2>
<ul>
<li><strong>Bump the version on every change that changes behaviour.</strong> A plug-in is a frozen copy on someone else's disk. Without a version that moves, you and a customer can't agree on what's installed.</li>
<li><strong>Lua 5.1 has one number type.</strong> Keep IDs and tokens from your API as strings, end to end.</li>
</ul>
<hr />
<p>If you're a photographer who'd rather publish straight from Lightroom than AirDrop photos to your phone, that's what we built: <a href="https://wedschedule.pro/lightroom">the WedSchedulePro Lightroom plug-in</a>.</p>
]]></content:encoded></item></channel></rss>