<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: hunter su</title>
    <description>The latest articles on DEV Community by hunter su (@hunter_su_a7c80321c2ab21a).</description>
    <link>https://dev.to/hunter_su_a7c80321c2ab21a</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4130703%2Ff5ca2547-8e48-44bc-bd2c-8ece3cbd87ea.png</url>
      <title>DEV Community: hunter su</title>
      <link>https://dev.to/hunter_su_a7c80321c2ab21a</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/hunter_su_a7c80321c2ab21a"/>
    <language>en</language>
    <item>
      <title>Four guardrails I learned while building an AI photo restoration workflow</title>
      <dc:creator>hunter su</dc:creator>
      <pubDate>Fri, 18 Sep 2026 07:08:24 +0000</pubDate>
      <link>https://dev.to/hunter_su_a7c80321c2ab21a/four-guardrails-i-learned-while-building-an-ai-photo-restoration-workflow-6fa</link>
      <guid>https://dev.to/hunter_su_a7c80321c2ab21a/four-guardrails-i-learned-while-building-an-ai-photo-restoration-workflow-6fa</guid>
      <description>&lt;p&gt;&lt;em&gt;Disclosure: I am affiliated with &lt;a href="https://restorephoto.org/" rel="noopener noreferrer"&gt;RestorePhoto&lt;/a&gt;, the product discussed in this post.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;A photo restoration demo is easy to understand: upload an old portrait, run a model, and show a sharper face.&lt;/p&gt;

&lt;p&gt;The product around that demo is harder. A face restoration model does not recover a hidden original. It predicts details that look plausible. That distinction affects the interface, error handling, billing, and even the words used on the result screen.&lt;/p&gt;

&lt;p&gt;While working on a browser-based restoration flow, I ended up treating the model as one uncertain step inside a stricter product state machine.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. Keep the original as the source of truth
&lt;/h2&gt;

&lt;p&gt;The restored image should never replace the upload silently. Users need both versions because they often recognize identity errors that an image metric will miss: an altered eye shape, a different smile, or skin texture that belongs to nobody in the family.&lt;/p&gt;

&lt;p&gt;A useful result view therefore needs:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;the original and restored image at the same scale;&lt;/li&gt;
&lt;li&gt;a comparison control that does not hide either version;&lt;/li&gt;
&lt;li&gt;separate download actions;&lt;/li&gt;
&lt;li&gt;a short disclosure near the result, not buried in the terms page.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The wording matters. "Recovered detail" implies that the system found information that was present but invisible. "AI-generated restoration" is more accurate. The model may have reconstructed a convincing eyelash or tooth that never existed in the source pixels.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Model the workflow as states, not a loading spinner
&lt;/h2&gt;

&lt;p&gt;An image job can fail before upload, during upload, in moderation, in the model provider, or while the result is being stored. A single &lt;code&gt;isLoading&lt;/code&gt; boolean quickly turns into contradictory UI.&lt;/p&gt;

&lt;p&gt;A small state machine is easier to reason about:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="nx"&gt;RestorationState&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;
  &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;idle&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;validating&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;file&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;File&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;uploading&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;file&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;File&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;progress&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;processing&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;jobId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;ready&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;jobId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;originalUrl&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;resultUrl&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;rejected&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;reason&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;unsupported&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;moderation&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;failed&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;retryable&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;boolean&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;message&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This makes several awkward cases explicit:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;validation failure should not create a paid job;&lt;/li&gt;
&lt;li&gt;a moderation rejection should not be presented as a model crash;&lt;/li&gt;
&lt;li&gt;refreshing the page during processing should resume by job ID;&lt;/li&gt;
&lt;li&gt;a failed job should not consume a credit unless the charging rule says it did.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The state model also gives analytics cleaner events. &lt;code&gt;upload_failed&lt;/code&gt;, &lt;code&gt;moderation_rejected&lt;/code&gt;, and &lt;code&gt;provider_failed&lt;/code&gt; are more useful than one generic &lt;code&gt;generation_error&lt;/code&gt; bucket.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Validate twice
&lt;/h2&gt;

&lt;p&gt;Client-side validation improves the experience, but it is not a security boundary. Check the file again on the server before handing it to another service.&lt;/p&gt;

&lt;p&gt;At minimum, I would verify:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;allowed MIME type and decoded image format;&lt;/li&gt;
&lt;li&gt;file size and pixel dimensions;&lt;/li&gt;
&lt;li&gt;whether the file can be decoded without exhausting memory;&lt;/li&gt;
&lt;li&gt;orientation metadata;&lt;/li&gt;
&lt;li&gt;moderation status before processing;&lt;/li&gt;
&lt;li&gt;that storage keys and result URLs belong to the current job.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Extensions are not enough. A file named &lt;code&gt;portrait.jpg&lt;/code&gt; can contain something else. Re-encoding an accepted upload into a known format can also remove unexpected metadata and reduce the number of formats passed downstream.&lt;/p&gt;

&lt;p&gt;The browser should show the limits before someone selects a file. A server error after a large upload is both expensive and irritating.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. Put the limitation where the decision happens
&lt;/h2&gt;

&lt;p&gt;A general AI disclaimer at the bottom of a website does little work. The useful warning appears when a user is deciding whether to trust or download the result.&lt;/p&gt;

&lt;p&gt;For face restoration, I use a direct boundary: the output is a best-effort AI reconstruction and may add plausible details. It should not be used for legal, forensic, immigration, or identity-verification purposes.&lt;/p&gt;

&lt;p&gt;That statement is deliberately unexciting. It tells a person what the tool can do and where it stops.&lt;/p&gt;

&lt;p&gt;The same principle applies to retention and billing. If an upload is deleted after a particular period, say when the clock starts. If a rejected or failed restoration is not charged, define "failed" in server-side terms and make the credit grant idempotent.&lt;/p&gt;

&lt;p&gt;For example, the billing transition should be tied to a durable successful result, not to the user clicking a button:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;request accepted
  -&amp;gt; moderation passed
  -&amp;gt; provider completed
  -&amp;gt; result stored
  -&amp;gt; grant/consume entitlement once
  -&amp;gt; return ready result
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Retries must reuse the same transaction key. Otherwise, a timeout between the result write and the response can charge twice.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I would test before launch
&lt;/h2&gt;

&lt;p&gt;My short release checklist now includes cases that a happy-path demo never shows:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;A renamed non-image file is rejected.&lt;/li&gt;
&lt;li&gt;A huge-dimension image fails before provider processing.&lt;/li&gt;
&lt;li&gt;Refreshing during processing resumes safely.&lt;/li&gt;
&lt;li&gt;Provider timeout does not produce a phantom success.&lt;/li&gt;
&lt;li&gt;Duplicate callbacks do not consume two credits.&lt;/li&gt;
&lt;li&gt;The original remains available beside the result.&lt;/li&gt;
&lt;li&gt;The limitation text is visible on mobile.&lt;/li&gt;
&lt;li&gt;Result URLs cannot be guessed across users.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;None of these tests improves the model. They improve the product around the model, which is where most user trust is won or lost.&lt;/p&gt;

&lt;p&gt;I am still refining this workflow in &lt;a href="https://restorephoto.org/" rel="noopener noreferrer"&gt;RestorePhoto&lt;/a&gt;. If you are building an image tool, I would be interested in the failure case that forced you to redesign your own upload or result flow.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>webdev</category>
      <category>product</category>
      <category>machinelearning</category>
    </item>
  </channel>
</rss>
