<?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: Tessa Rhodes</title>
    <description>The latest articles on DEV Community by Tessa Rhodes (@tessarhodes).</description>
    <link>https://dev.to/tessarhodes</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%2F4186647%2Fc521bb94-55e8-4a88-a2d6-840c80dc3c30.png</url>
      <title>DEV Community: Tessa Rhodes</title>
      <link>https://dev.to/tessarhodes</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/tessarhodes"/>
    <language>en</language>
    <item>
      <title>Turning a viral TikTok AI trend into a one-photo template: the pipeline behind it</title>
      <dc:creator>Tessa Rhodes</dc:creator>
      <pubDate>Wed, 28 Oct 2026 14:43:50 +0000</pubDate>
      <link>https://dev.to/tessarhodes/turning-a-viral-tiktok-ai-trend-into-a-one-photo-template-the-pipeline-behind-it-5h4o</link>
      <guid>https://dev.to/tessarhodes/turning-a-viral-tiktok-ai-trend-into-a-one-photo-template-the-pipeline-behind-it-5h4o</guid>
      <description>&lt;p&gt;AI video trends on TikTok come and go every few days, so a page for one has to ship while the trend is still rising. This post walks through the pipeline I built to do that: trend config, link resolver, reference-video template, credit reservation, polling, portrait reframe and failure handling, with code from the repo.&lt;/p&gt;

&lt;p&gt;The product is simple from the outside. You pick a trend (or paste the TikTok link you saw), add one photo, and get the same clip with you, your pet or your car in it, as a vertical MP4 with sound. Underneath, every one of those steps had at least one way to fail that I only found by shipping it.&lt;/p&gt;

&lt;h2&gt;
  
  
  A trend is a config file plus a database flag
&lt;/h2&gt;

&lt;p&gt;The first decision was where a trend lives. I tried keeping trends purely in the database and gave up quickly: each page carries a lot of hand-checked content (which TikTok post is the reference, which second of it the story beats come from, which versions people remake), and I wanted that reviewed in code, with diffs. So the content is a TypeScript object, and whether it is live is one column in a &lt;code&gt;trend&lt;/code&gt; table.&lt;/p&gt;

&lt;p&gt;A page is public only when both are true: its config exists in &lt;code&gt;TREND_PAGES&lt;/code&gt; &lt;strong&gt;and&lt;/strong&gt; its row says &lt;code&gt;page_status = 'published'&lt;/code&gt;. Drafts render for admins with a noindex tag and a "Draft" bar; everyone else gets a 404. The status is a small state machine:&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="cm"&gt;/**
 * none = no page in code; draft = in code, waiting for approval;
 * held = turned down for now; published = public.
 */&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="nx"&gt;PageStatus&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;none&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="s1"&gt;draft&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="s1"&gt;held&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="s1"&gt;published&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="nx"&gt;WallStatus&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;pending&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="s1"&gt;approved&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="s1"&gt;hidden&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The home page "wall" reads from the same table with its own &lt;code&gt;wall_status&lt;/code&gt;, so a trend can appear as a card before its page exists. There is also a quality floor in code rather than in someone's head: no TikTok post with fewer than 200,000 plays is shown anywhere on the site (&lt;code&gt;MIN_PLAYS = 200_000&lt;/code&gt;), and remakes shown under a trend need 100,000. A checker script holds every page to those numbers. Each page also records the seconds where its story beats happen, and a script cuts a still at each of them for the page's "what happens, in order" strip.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fgewqbm16kcqocs6fdf9h.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fgewqbm16kcqocs6fdf9h.png" alt="The &amp;quot;What happens, in order&amp;quot; strip on the Cat Dance AI page, four stills cut from the reference clip at fixed seconds" width="800" height="386"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Splitting content (code) from state (database) is what made "ship a page in an afternoon" possible: the content gets reviewed like any other change, and going live is a one-row update. The next problem was getting people from a TikTok link to the right page.&lt;/p&gt;

&lt;h2&gt;
  
  
  The paste box: resolving a TikTok share link without becoming an open proxy
&lt;/h2&gt;

&lt;p&gt;Many visitors arrive having just watched the trend on their phone. The fastest path is "paste the link you saw", so the home page has a single input. What people actually paste is rarely a clean URL. It is the whole share text from the TikTok app, a caption plus emoji plus a &lt;code&gt;vm.tiktok.com&lt;/code&gt; short link, often opened inside TikTok's in-app browser.&lt;/p&gt;

&lt;p&gt;The resolver does three things: pull the first URL out of whatever was pasted, follow short links &lt;strong&gt;by hand&lt;/strong&gt;, and only ever follow TikTok hosts. That last part matters, because a server that follows any redirect a stranger hands it is an open proxy into your network.&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;const&lt;/span&gt; &lt;span class="nx"&gt;TIKTOK_HOST&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="se"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;^|&lt;/span&gt;&lt;span class="se"&gt;\.)&lt;/span&gt;&lt;span class="sr"&gt;tiktok&lt;/span&gt;&lt;span class="se"&gt;\.&lt;/span&gt;&lt;span class="sr"&gt;com$/i&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;SHORT_HOST&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sr"&gt;/^&lt;/span&gt;&lt;span class="se"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;vm|vt&lt;/span&gt;&lt;span class="se"&gt;)\.&lt;/span&gt;&lt;span class="sr"&gt;tiktok&lt;/span&gt;&lt;span class="se"&gt;\.&lt;/span&gt;&lt;span class="sr"&gt;com$/i&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;hop&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;hop&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;hop&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;direct&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;parseAwemeId&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;          &lt;span class="c1"&gt;// /@user/video/&amp;lt;15-22 digits&amp;gt;&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;direct&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;awemeId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;direct&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;TIKTOK_HOST&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;test&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;URL&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nx"&gt;hostname&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;resp&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;redirect&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;manual&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;                       &lt;span class="c1"&gt;// we read Location ourselves&lt;/span&gt;
    &lt;span class="na"&gt;signal&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;AbortSignal&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;timeout&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;5000&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;next&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;resp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;location&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;next&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;break&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nx"&gt;url&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;URL&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;next&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;toString&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Once there is a video id, it is matched against every published page's known post ids (the reference, the remakes, and the ids the trend feed saw). A match returns &lt;code&gt;{ slug }&lt;/code&gt; and the browser navigates to that page. Anything else is stored as a &lt;code&gt;copy_request&lt;/code&gt; row and the visitor is told we will look at it. No video is fetched or stored at this step. That table doubles as the backlog: a list of trends real people tried to copy and could not. The endpoint is rate limited to 20 links per 10 minutes per IP.&lt;/p&gt;

&lt;h2&gt;
  
  
  A template is a reference clip plus a prompt with photo slots
&lt;/h2&gt;

&lt;p&gt;With the visitor on a trend page, the generator needs a recipe. The rule every template follows: the person is new, the scene is not. So the model gets the scene as a &lt;strong&gt;reference video&lt;/strong&gt; and the visitor's photo as an &lt;strong&gt;image reference&lt;/strong&gt;, and the prompt asks it to keep everything except the subject: the car, the camera move, the beats and the sound.&lt;/p&gt;

&lt;p&gt;Here is one template from the Cat Dance AI page, trimmed and translated (the real prompt is longer and names every beat in order):&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="nx"&gt;generation&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;mode&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;reference-video&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;photos&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[{&lt;/span&gt; &lt;span class="na"&gt;key&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;cat&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;label&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;en&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Your cat&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
             &lt;span class="na"&gt;hint&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;en&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;One cat, alone, face and ears clear. A dog or another pet works too.&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;}],&lt;/span&gt;
  &lt;span class="nx"&gt;prompt&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Replace the white kitten leaning out of the taxi window in @video1 with the animal in @image1. &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="s1"&gt;Fur, markings, face and build all follow @image1. Keep the scene, camera, movement and rhythm of @video1. &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="s1"&gt;Keep its sound, add no narration. No text, captions, logos or watermarks.&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;referenceVideoUrl&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;&amp;lt;our 10 s copy of the post, sound kept&amp;gt;&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;durationSec&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;aspectRatio&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;9:16&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;generateAudio&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;etaMinutes&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;6&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;@image1&lt;/code&gt;, &lt;code&gt;@image2&lt;/code&gt; are the uploaded photos in slot order; &lt;code&gt;@video1&lt;/code&gt; is the reference. The visitor's photos are always sent first, then any template-side images (a costume, a prop), so the numbering in the prompt stays stable. &lt;code&gt;etaMinutes&lt;/code&gt; is what the waiting screen shows. There is also an optional note field, capped at 200 characters, which is appended last so it can override background or lighting. It is stripped of &lt;code&gt;@&lt;/code&gt;, braces and newlines before it goes anywhere near the prompt, so nobody can type &lt;code&gt;@video1&lt;/code&gt; into it and point the model at our inputs.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F68pxu7so1btyvd1g98ow.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F68pxu7so1btyvd1g98ow.png" alt="The Cat Dance AI generator: four templates, one photo slot, a Standard/HD toggle and the spend button showing the exact credit cost" width="800" height="611"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Every template on the page maps to one version people actually remade, in the order the tutorials showed them, which is why the generator above has four. The interesting bugs started when templates got choices.&lt;/p&gt;

&lt;h2&gt;
  
  
  Edit or new clip: the parameter trap, and reframing to portrait
&lt;/h2&gt;

&lt;p&gt;Some trends are landscape originals, and people want to post them vertically. The Gangnam Style page lets the visitor pick Landscape or Portrait, and one group photo or a photo each. Those two choices change the kind of request I send.&lt;/p&gt;

&lt;p&gt;A landscape run is an &lt;strong&gt;edit&lt;/strong&gt; of the reference. On October 16 I found out the hard way that the model treats "replace the player in @video1" as a video edit, and an edit only accepts a duration of &lt;code&gt;-1&lt;/code&gt; (as long as the reference) and an &lt;code&gt;adaptive&lt;/code&gt; aspect ratio. Send anything else and the task is &lt;em&gt;accepted&lt;/em&gt;, then fails about a minute later with a parameter-constraint error. That is the worst failure mode: the visitor has already waited, and the charge has to be undone. So the plan builder decides up front:&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;const&lt;/span&gt; &lt;span class="nx"&gt;portrait&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;picked&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;frame&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;portrait&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="o"&gt;!!&lt;/span&gt;&lt;span class="nx"&gt;gen&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;choices&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;frame&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;edit&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="o"&gt;!!&lt;/span&gt;&lt;span class="nx"&gt;reference&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;portrait&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="nl"&gt;task&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;durationSec&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;edit&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;gen&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;durationSec&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;aspectRatio&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;edit&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;adaptive&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;portrait&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;9:16&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;gen&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;aspectRatio&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A portrait run cannot edit a landscape reference (an edit keeps the frame), so it uses a second prompt, &lt;code&gt;portraitPrompt&lt;/code&gt;, that makes a &lt;em&gt;new&lt;/em&gt; clip following the reference at 9:16. Even then, a model sometimes returns another shape. Every trend video leaves the site portrait, so after download it goes through ffmpeg: anything more than 6% off 9:16 is centered on 720Ã—1280 over a blurred, darkened copy of itself, sound untouched. The blur runs at an eighth of the size, which is cheaper and looks just as soft:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;[0:v]split=2[bg][fg];
[bg]scale=90:160:force_original_aspect_ratio=increase,crop=90:160,
    boxblur=6:2,colorchannelmixer=rr=0.6:gg=0.6:bb=0.6,scale=720:1280[b];
[fg]scale=720:1280:force_original_aspect_ratio=decrease:force_divisible_by=2[f];
[b][f]overlay=(W-w)/2:(H-h)/2,setsar=1,format=yuv420p[v]
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If ffmpeg fails, the video is kept as rendered and I get an alert. A slightly wrong shape is better than no video.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F6azndsc836zrl3gxfh5e.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F6azndsc836zrl3gxfh5e.png" alt="The Gangnam Style AI generator with &amp;quot;One group photo / A photo each&amp;quot; and &amp;quot;Landscape / Portrait&amp;quot; choices" width="800" height="611"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Credits: reserve first, render second, refund on every failure
&lt;/h2&gt;

&lt;p&gt;Video renders cost real money per second, so the order of operations is strict, and the comment in the code says it in one line: &lt;em&gt;reserve first, render second: a render that cannot be paid for never starts, and a render that fails gives the reservation back.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Before anything is charged, the run is fully planned and validated. Photos are only accepted if they are HTTPS URLs on our own storage host under &lt;code&gt;uploads/&lt;/code&gt;, and each one gets a &lt;code&gt;HEAD&lt;/code&gt; request, because a photo from an earlier run may have been deleted with its video. Handing the model a dead link would fail &lt;em&gt;after&lt;/em&gt; charging. Billing rounds every started second up, with an epsilon so float noise does not add a whole second:&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="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;billedSeconds&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;seconds&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="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;min&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ceil&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;seconds&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;6&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="nx"&gt;MAX_TEMPLATE_SECONDS&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// 15&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;After submission, the run is a row that gets polled. Polling is safe from any number of places at once (the open page, the "My videos" list, a cron), because closing a run goes through one conditional update that only one caller wins. The timings in the code today:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Constant&lt;/th&gt;
&lt;th&gt;Value&lt;/th&gt;
&lt;th&gt;Why&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;POLL_EVERY_MS&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;4,000&lt;/td&gt;
&lt;td&gt;polling faster only burns the provider's rate limit&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;KEEP_HOLD_SEC&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;150&lt;/td&gt;
&lt;td&gt;one poller holds a finished run while it downloads and reframes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;GIVE_UP_AFTER_MIN&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;30&lt;/td&gt;
&lt;td&gt;no real render takes this long; fail and refund&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;never reached provider&lt;/td&gt;
&lt;td&gt;5 min&lt;/td&gt;
&lt;td&gt;the submit died with its function; fail and refund&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;unused photo&lt;/td&gt;
&lt;td&gt;24 h&lt;/td&gt;
&lt;td&gt;deleted by the cron if no run used it&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The finished file is copied into our own storage right away, because provider links expire and "My videos" has to keep working. The pack page reflects the same rule in plain words: one-time packs, credits never expire, and failed renders give their credits back.&lt;/p&gt;

&lt;h2&gt;
  
  
  Telling people &lt;em&gt;why&lt;/em&gt; a render failed
&lt;/h2&gt;

&lt;p&gt;The first version showed one message for every failure: "Something went wrong, try again in a minute." On October 26 one visitor sent the same photos six times in a row after seeing it. The model had refused them for showing a real person, and it was going to refuse them every time. Each failure was refunded, but the message had sent them in a loop.&lt;/p&gt;

&lt;p&gt;Now the failure reason from the provider is sorted into three kinds before anything is shown:&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;const&lt;/span&gt; &lt;span class="nx"&gt;CONTENT_RULES&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;
  &lt;span class="sr"&gt;/sensitive|content &lt;/span&gt;&lt;span class="se"&gt;(?:&lt;/span&gt;&lt;span class="sr"&gt;policy|filter|safety|moderation|check|risk|violation&lt;/span&gt;&lt;span class="se"&gt;)&lt;/span&gt;&lt;span class="sr"&gt;|safety|moderat|nsfw|violen|sexual|nud&lt;/span&gt;&lt;span class="se"&gt;(?:&lt;/span&gt;&lt;span class="sr"&gt;e|ity&lt;/span&gt;&lt;span class="se"&gt;)&lt;/span&gt;&lt;span class="sr"&gt;|porn|inappropriate|prohibited|policy/i&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;PHOTO_RULES&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;
  &lt;span class="sr"&gt;/real &lt;/span&gt;&lt;span class="se"&gt;(?:&lt;/span&gt;&lt;span class="sr"&gt;person|people&lt;/span&gt;&lt;span class="se"&gt;)&lt;/span&gt;&lt;span class="sr"&gt;|input image|aspect ratio|too &lt;/span&gt;&lt;span class="se"&gt;(?:&lt;/span&gt;&lt;span class="sr"&gt;narrow|wide|small|short|tall&lt;/span&gt;&lt;span class="se"&gt;)&lt;/span&gt;&lt;span class="sr"&gt;/i&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;failureKind&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;reason&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;content&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="s1"&gt;photo&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="s1"&gt;other&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;reason&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;other&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;CONTENT_RULES&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;test&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;reason&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;content&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;PHOTO_RULES&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;test&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;reason&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;photo&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;other&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;content&lt;/code&gt; says plainly that the model's rules stopped it. &lt;code&gt;photo&lt;/code&gt; says to change the photo (too narrow, wrong shape, or read as a real person), not to retry. Only &lt;code&gt;other&lt;/code&gt; keeps "try again". For content and real-person refusals, a background job asks a text model which photo was the likely cause, and the page shows that hint if it arrives. The rest of the run never waits on it, so a slow hint can't hold anything up.&lt;/p&gt;

&lt;p&gt;Separately, every uploaded photo goes through an automatic check for nudity and sexual content. That check runs after the render has started and only raises an alert, so it never blocks a legitimate video. The video model's own rules are the hard gate.&lt;/p&gt;

&lt;h2&gt;
  
  
  Multi-person templates: draw keyframes, check them, then render
&lt;/h2&gt;

&lt;p&gt;Group templates were the last hard part. With three people in a scene, the video model has to keep three faces apart in a single pass, and one wrong face ruins the clip. The fix, added this week, is a keyframe step before the video: for each shot of the reference, an image model draws a still with the visitor's people placed in it, and a text model checks the still against their photos.&lt;/p&gt;

&lt;p&gt;The checker's contract is deliberately tiny. It is asked whether each person is clearly the same as their photo, in the right place, shown once, with no text or warped hands, and it must answer with JSON only:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"ok"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"why"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"The person on the left has a different hairstyle than their photo."&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A failed still is drawn again, three tries at most. Only stills that passed are attached, after the photos, and the prompt's &lt;code&gt;{kf-&amp;lt;shot&amp;gt;}&lt;/code&gt; placeholders become a pointer like "frame follows @image6" for those shots; with none, the placeholders are empty and the prompt reads as it did before keyframes. The whole step has a 200-second budget so it fits inside the route's 300-second limit with room to submit the video.&lt;/p&gt;

&lt;p&gt;Two failure cases needed their own code. Drawn stills can themselves trip the "real person" refusal, so on that error the run is submitted once more &lt;strong&gt;without&lt;/strong&gt; them. And when the keyframe step dies with its function, any run still waiting after six minutes goes ahead without keyframes rather than not at all.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I would keep, and where to see it
&lt;/h2&gt;

&lt;p&gt;If I started again, I would keep three things. Content in code, state in the database. Money reserved before any work and refunded on every exit path. Failure messages that tell the person what to change. Everything else (the keyframes, the reframe, the link resolver) was added because a real run failed in a specific way, and each fix is a few dozen lines next to a comment with the date it broke.&lt;/p&gt;

&lt;p&gt;The other lesson is about scope. Each trend gets its own page with templates that match the versions people actually remade, instead of one generic "upload a photo and describe a video" box. That is more work per trend, but the visitor never has to write a prompt, and the reference clip does the work a long scene description would otherwise have to do. If you want to see the pipeline from the user side, the wall of &lt;a href="https://copythatvideo.com/" rel="noopener noreferrer"&gt;AI video trends&lt;/a&gt; shows which templates are live right now. The newest one, &lt;a href="https://copythatvideo.com/outstanding-tiny-desk-ai" rel="noopener noreferrer"&gt;Outstanding Tiny Desk AI&lt;/a&gt;, runs on exactly this pipeline: one photo in, the reference clip's framing and sound out, as a vertical MP4.&lt;/p&gt;

&lt;p&gt;On the creator side, I keep a short workflow note on what to keep from a trend (the first-second hook, the cut rhythm, the on-screen text pattern) and what to change so the result is yours: &lt;a href="https://sites.google.com/view/tessarhodes-video-notes" rel="noopener noreferrer"&gt;my trend-remix notes&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Questions about the reference-video approach, the reframe filter or the credit state machine are welcome in the comments.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>webdev</category>
      <category>video</category>
      <category>showdev</category>
    </item>
    <item>
      <title>Designing for skin, not screens: the constraints behind an AI tattoo design tool</title>
      <dc:creator>Tessa Rhodes</dc:creator>
      <pubDate>Thu, 15 Oct 2026 11:16:48 +0000</pubDate>
      <link>https://dev.to/tessarhodes/designing-for-skin-not-screens-the-constraints-behind-an-ai-tattoo-design-tool-dd3</link>
      <guid>https://dev.to/tessarhodes/designing-for-skin-not-screens-the-constraints-behind-an-ai-tattoo-design-tool-dd3</guid>
      <description>&lt;p&gt;Most AI image tools can draw a beautiful tattoo. Far fewer can draw one an artist can actually put on skin. When I started building TattooGenerator, the hard part was never "make a nice picture". It was encoding the rules tattoo artists apply without thinking: how big the piece will be, how thin a line can go, and what the design will look like after ten years in skin.&lt;/p&gt;

&lt;p&gt;These are the constraints that shaped the product, and why each one exists.&lt;/p&gt;

&lt;h2&gt;
  
  
  The screen lies about size
&lt;/h2&gt;

&lt;p&gt;A tattoo design on a phone is about ten centimetres tall, backlit and perfectly flat. On a wrist it is 3–7cm across, sitting on skin that stretches, wrinkles and fades. Detail that reads perfectly on screen becomes a grey smudge at actual size, and nobody notices until it has healed.&lt;/p&gt;

&lt;p&gt;So the first real input isn't the prompt, it's the placement. Picking "wrist" or "forearm" changes the composition the generator aims for: a wrist piece is drawn for 3–7cm from the start instead of being a back piece shrunk down. That single decision removes the most common failure I kept seeing in AI tattoo designs: more detail than the skin can physically hold.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F9ll2u67ta7wl91zgsqog.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F9ll2u67ta7wl91zgsqog.png" alt="A row of AI tattoo designs, each drawn for a specific style and placement" width="800" height="479"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;It also changed how I think about "quality". A design that looks sparse on a 27-inch monitor is often exactly right for a 5cm piece on an ankle.&lt;/p&gt;

&lt;h2&gt;
  
  
  Styles are rule sets, not filters
&lt;/h2&gt;

&lt;p&gt;The second lesson: tattoo styles are not aesthetic filters. Each one is a set of constraints that artists learned the hard way.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Style&lt;/th&gt;
&lt;th&gt;Line rule&lt;/th&gt;
&lt;th&gt;Fill rule&lt;/th&gt;
&lt;th&gt;Why it matters on skin&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Fine line&lt;/td&gt;
&lt;td&gt;Thin, single-weight strokes&lt;/td&gt;
&lt;td&gt;No shading&lt;/td&gt;
&lt;td&gt;Thin lines spread over time, so they need space between them&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Blackwork&lt;/td&gt;
&lt;td&gt;Bold shapes&lt;/td&gt;
&lt;td&gt;Solid black&lt;/td&gt;
&lt;td&gt;One of the most durable styles; holds contrast for decades&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Traditional&lt;/td&gt;
&lt;td&gt;Bold, even outlines&lt;/td&gt;
&lt;td&gt;Flat, limited palette&lt;/td&gt;
&lt;td&gt;Why old-school pieces still read after thirty years&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Japanese&lt;/td&gt;
&lt;td&gt;Flowing composition, wind bars&lt;/td&gt;
&lt;td&gt;Bold outlines, solid backgrounds&lt;/td&gt;
&lt;td&gt;Large shapes designed to age well on sleeves and backs&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Treating a style as a filter on top of one generic drawing produces the same composition with a different texture: "fine line" that is secretly shaded, "traditional" with photographic gradients. Treating it as a rule set means the same subject genuinely comes back different. A koi in Japanese style becomes a flowing piece with waves and wind bars; in fine line it is an open, single-weight outline with room to breathe.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fe7wjwk89ffw4r6m3y1lm.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fe7wjwk89ffw4r6m3y1lm.png" alt="Tattoo styles explained side by side: fine line, blackwork, traditional, neo traditional, Japanese and realism" width="800" height="479"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Detail density is the real quality metric
&lt;/h2&gt;

&lt;p&gt;Ask tattoo artists what is wrong with the AI designs clients bring in, and the answer is usually density. Lines packed so tightly they close up into a solid patch within a year. Inconsistent line weights inside one piece. Micro-texture no needle can reproduce.&lt;/p&gt;

&lt;p&gt;"Does it look good?" turned out to be the wrong check. "Will every element still read at arm's length, at actual size, in ten years?" is the right one. It is a stricter bar, and it changes what a good output looks like: simpler, more open, with deliberate negative space. In practice that meant favouring designs that some people find plain at first glance, because those are the ones that survive.&lt;/p&gt;

&lt;h2&gt;
  
  
  Artists need a stencil, not a JPEG
&lt;/h2&gt;

&lt;p&gt;Artists transfer a design onto skin with thermal stencil paper. A shaded, coloured image is a reference; a black-and-white line drawing at the right size is a working file.&lt;/p&gt;

&lt;p&gt;So exports were built around that hand-off. One click turns a finished design into clean, high-contrast linework at the chosen size, ready to print on thermal paper. That is the &lt;a href="https://tattoogenerator.ink/tattoo-stencil-maker" rel="noopener noreferrer"&gt;Tattoo Stencil Maker&lt;/a&gt;, and the before/after below is real output.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fgwpcy3oawb5kqf4g92fv.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fgwpcy3oawb5kqf4g92fv.png" alt="Before and after: shaded tattoo designs converted into clean stencil linework" width="800" height="479"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;This also clarified what the product is for. It is not a replacement for an artist. The artist still adjusts the composition to the body, fixes line weight and does the actual work. It is a way to start the consultation from your idea instead of someone else's saved picture.&lt;/p&gt;

&lt;h2&gt;
  
  
  Preview on the real body part
&lt;/h2&gt;

&lt;p&gt;Even a perfectly sized design can look wrong once it wraps around a forearm. The fix is boring and effective: put the design on a photo of the actual spot before booking. Most "it's too big" and "it should be angled" conversations happen in that preview, not in the chair, which is cheaper for everyone.&lt;/p&gt;

&lt;h2&gt;
  
  
  Three inputs, because ideas don't start as prompts
&lt;/h2&gt;

&lt;p&gt;People rarely arrive with a clean text prompt. They arrive with a photo of their dog, a grandmother's handwriting, a date, or a half-formed idea. So there are three starting points: text, a photo, or lettering.&lt;/p&gt;

&lt;p&gt;Lettering got its own path because names and dates fail in a specific way: a script that looks elegant at 20cm becomes illegible at 4cm. A free preview of a name in 12 tattoo fonts exists to catch that before anything is generated.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'd tell anyone building for a physical medium
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Start from the physical constraint (size, material, ageing), not from what the model happens to output.&lt;/li&gt;
&lt;li&gt;Encode domain rules per mode, instead of bolting them on as post-processing.&lt;/li&gt;
&lt;li&gt;Export the file the professional actually works with. Here, that is a stencil.&lt;/li&gt;
&lt;li&gt;Keep the human expert in the loop, and say so plainly.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you want to see how this plays out, the &lt;a href="https://tattoogenerator.ink/" rel="noopener noreferrer"&gt;AI tattoo generator&lt;/a&gt; is live. A free account comes with starter credits, a text design costs 1 credit and a photo design 2, and failed generations are refunded.&lt;/p&gt;

&lt;p&gt;I'd love to hear from anyone who has handed an AI-made design to a tattoo artist: what did they change first?&lt;/p&gt;

</description>
      <category>showdev</category>
      <category>ai</category>
      <category>design</category>
      <category>webdev</category>
    </item>
  </channel>
</rss>
