<?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: Muhamad Revanza Rafif Hamdani</title>
    <description>The latest articles on DEV Community by Muhamad Revanza Rafif Hamdani (@revanzart).</description>
    <link>https://dev.to/revanzart</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%2F4151595%2F31b958fc-6e04-437c-bc2b-8d265f1dd23b.jpg</url>
      <title>DEV Community: Muhamad Revanza Rafif Hamdani</title>
      <link>https://dev.to/revanzart</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/revanzart"/>
    <language>en</language>
    <item>
      <title>The 1280 720 Lesson: Why a Zip Isn't Shippable Until It Has a Cover"**</title>
      <dc:creator>Muhamad Revanza Rafif Hamdani</dc:creator>
      <pubDate>Tue, 10 Nov 2026 17:12:41 +0000</pubDate>
      <link>https://dev.to/revanzart/the-1280-720-lesson-why-a-zip-isnt-shippable-until-it-has-a-cover-41pm</link>
      <guid>https://dev.to/revanzart/the-1280-720-lesson-why-a-zip-isnt-shippable-until-it-has-a-cover-41pm</guid>
      <description>&lt;p&gt;First paid sale: $2.80. Digital Asset Factory. By then I had 1,860 assets made and 1,077 ready or submitted. The sale did not come from better art. It came from a zip that finally had a cover on it.&lt;/p&gt;

&lt;p&gt;I shipped zips without covers for weeks. Bare folder, zip it, upload it, wonder why nobody clicked. The files were fine. The listing was not a listing. It was a gray placeholder in a grid of thumbnails that actually looked like products.&lt;/p&gt;

&lt;h2&gt;
  
  
  A zip is not a product until it renders as one
&lt;/h2&gt;

&lt;p&gt;Storefronts show your package as an image before anyone reads a word. No image, no click. Every pack I build now writes three files in one pass: the zip, a 1280x720 cover, and a 600x600 thumbnail. Not "nice to have". The pack step does all three because I will skip an optional step every single time, and so will you.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;pack_product.py&lt;/code&gt; takes the folder, a title, a subtitle, and a count, then writes all three into a &lt;code&gt;_system&lt;/code&gt; folder next to where you run it. One command, no editing the script, &lt;code&gt;--dir&lt;/code&gt; or &lt;code&gt;PACK_ROOT&lt;/code&gt; if you want it elsewhere.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why 1280x720 and not something clever
&lt;/h2&gt;

&lt;p&gt;The preview box is wide. 1280x720 is 16:9, so it fills that box with no letterbox bars, no crop guessing. It is also small enough that Pillow renders it on a 1 vCPU box in well under a second, which matters when you are packing 39 products in a row. 600x600 is the square size, for the grids that crop to square. Two sizes, two renders, one source image. I tried a third size once. It added a render pass and changed nothing about sales.&lt;/p&gt;

&lt;p&gt;Rule I keep: cover and thumbnail come from the same source, generated in the same run as the zip. If the zip is new, the cover is new. Stale covers are worse than none because they still look real.&lt;/p&gt;

&lt;h2&gt;
  
  
  The cover is cheap. The metadata header is the gate.
&lt;/h2&gt;

&lt;p&gt;Covers cost a render. Metadata costs a rejection.&lt;/p&gt;

&lt;p&gt;Adobe takes a 5 column header. Vecteezy takes 4. Dreamstime takes 15. Wrong header, or a column out of order, and the import fails instantly. Not after review. Immediately, whole batch.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;make_metadata.py&lt;/code&gt; writes exactly the header each channel demands and nothing else. It builds the CSV from a folder of files, one flag for the platform, one for the extension. Stdlib only, so no install. Without &lt;code&gt;--titles titles.json&lt;/code&gt; it title-cases the filename and uses that as the title. That is fine for a first pass and wrong for a submission. Replace the titles before you upload.&lt;/p&gt;

&lt;h2&gt;
  
  
  A re-run should be boring
&lt;/h2&gt;

&lt;p&gt;Fastest way to get an account flagged is running your batch twice. Second upload of the same 40 files looks exactly like a bot.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;upload_tracker.py&lt;/code&gt; is a ledger. &lt;code&gt;pending&lt;/code&gt; shows what is not done. &lt;code&gt;done "asset-001.jpg"&lt;/code&gt; marks one. &lt;code&gt;status&lt;/code&gt; prints the count. Rerun the batch, nothing posts twice. It is a text file and a few subcommands, standard library, and it is the single cheapest protection in the pipeline.&lt;/p&gt;

&lt;h2&gt;
  
  
  All of it ran on 1 vCPU and 2 GB
&lt;/h2&gt;

&lt;p&gt;No GPU, no cloud account, no fan-out. What made that work:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;One process at a time. Never parallel image jobs on one core.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;gc.collect()&lt;/code&gt; after each asset.&lt;/li&gt;
&lt;li&gt;Measure peak RSS, not average: &lt;code&gt;resource.getrusage(RUSAGE_SELF).ru_maxrss&lt;/code&gt;. Average hides the spike that kills you.&lt;/li&gt;
&lt;li&gt;Heavy render steps in their own subprocess so memory goes back to the OS.&lt;/li&gt;
&lt;li&gt;Render at the resolution you actually need. Inspecting a preview at 60 dpi beats rendering it at 300.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That list is boring on purpose. Boring is why it finished 39 products instead of dying at number 12.&lt;/p&gt;

&lt;h2&gt;
  
  
  The folder layout that survives a catalogue
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;project/
  assets/     raw output, one file per asset
  output/     ready-to-submit files + metadata.csv
  packs/      zips, covers, thumbnails
  research/   notes and sources
  scripts/    the tools
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Keep &lt;code&gt;research/&lt;/code&gt; forever. Scrape logs and notes are raw material for the next product. I deleted a folder to "clean up" once. Will not do it again.&lt;/p&gt;

&lt;h2&gt;
  
  
  Numbers, not rounded up
&lt;/h2&gt;

&lt;p&gt;1,860 assets made. 1,077 ready or submitted. 39 products packaged, 856 MB of output. About 19,000 lines of pipeline code in the parent project, and 300+ pages of research behind the rules the scripts enforce. Validated on 1 vCPU, 2 GB RAM, no GPU. First paid sale: $2.80.&lt;/p&gt;

&lt;p&gt;That $2.80 is the whole point. It was not the biggest asset or the best vector. It was the first package that looked like something worth opening, because the zip shipped with a cover and correct metadata, generated in the same run, on the same tiny box.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where this lives
&lt;/h2&gt;

&lt;p&gt;The four scripts above are the packaging layer of a bigger system: metadata CSVs for three channels, the upload ledger, a Markdown to PDF step, and the pack step that forces a cover. I put the whole thing together as Digital Asset Factory, including the folder conventions, the RAM habits, and the rules the scripts enforce, so you do not have to rediscover the 1280x720 lesson by shipping bare zips for three weeks.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://revanzart-shop.revanzart.workers.dev/go/digital-asset-factory?src=devto" rel="noopener noreferrer"&gt;Get digital asset factory&lt;/a&gt;&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Building digital products? &lt;a href="https://revanzart-shop.revanzart.workers.dev/guides" rel="noopener noreferrer"&gt;Free guides &amp;amp; kits for digital product sellers&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>sideprojects</category>
      <category>ai</category>
      <category>automation</category>
      <category>marketing</category>
    </item>
    <item>
      <title>Why pack_product.py Renders Both a 1280Ã 720 Cover and a 600Ã 600 Thumbnail</title>
      <dc:creator>Muhamad Revanza Rafif Hamdani</dc:creator>
      <pubDate>Tue, 10 Nov 2026 17:12:29 +0000</pubDate>
      <link>https://dev.to/revanzart/why-packproductpy-renders-both-a-1280a-720-cover-and-a-600a-600-thumbnail-1mj9</link>
      <guid>https://dev.to/revanzart/why-packproductpy-renders-both-a-1280a-720-cover-and-a-600a-600-thumbnail-1mj9</guid>
      <description>&lt;p&gt;I lost an hour to a cover image once, and I want to explain why that hour is now two lines of Python.&lt;/p&gt;

&lt;p&gt;The moment: I had finished a product zip, thrown a screenshot of one of the assets on top as the cover, and uploaded it. The listing looked fine on my laptop. On the sales page it was a 900×500 rectangle sitting in a grid of square thumbnails, cropped weird, title unreadable. I fixed it by hand, then fixed it again on the next product, then wrote &lt;code&gt;pack_product.py&lt;/code&gt; so I would never fix it by hand again.&lt;/p&gt;

&lt;p&gt;Here is what the script does and why it renders two images instead of one.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two images, because there are two surfaces
&lt;/h2&gt;

&lt;p&gt;Every place your product shows up falls into one of two shapes. Wide banners, like a Gumroad or Payhip hero image, want something close to 1280×720. Square grids, like marketplace listings and category pages, want 600×600. If you upload one image and let the platform crop it, the platform decides what gets cut off. It will usually cut off your title.&lt;/p&gt;

&lt;p&gt;So &lt;code&gt;pack_product.py&lt;/code&gt; renders both from the same source folder:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;my-product-cover.jpg&lt;/code&gt;, 1280×720, with the product title and subtitle laid in&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;my-product-thumb.jpg&lt;/code&gt;, 600×600, centered so nothing important sits near an edge&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;One command:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;python pack_product.py my-product &lt;span class="s2"&gt;"My Product"&lt;/span&gt; &lt;span class="s2"&gt;"A short subtitle"&lt;/span&gt; 9
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;9&lt;/code&gt; is the number of preview assets to pull from the folder for the collage. It writes &lt;code&gt;my-product.zip&lt;/code&gt; plus both images into a &lt;code&gt;_system&lt;/code&gt; folder. You can move that with &lt;code&gt;--dir&lt;/code&gt; or the &lt;code&gt;PACK_ROOT&lt;/code&gt; environment variable, so the script never needs editing.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why Pillow and why it fits a 2 GB server
&lt;/h2&gt;

&lt;p&gt;The whole pipeline runs on 1 vCPU, 2 GB RAM, no GPU. Pillow is the only imaging dependency, and it is enough. The cover is text and a grid of preview images, not a render job.&lt;/p&gt;

&lt;p&gt;Two habits matter at this size. First, one process at a time. Fanning out image jobs in parallel on a single core just adds swap pressure. Second, Pillow loads full decoded bitmaps into RAM, so the script calls &lt;code&gt;gc.collect()&lt;/code&gt; after each asset and I measure peak RSS with &lt;code&gt;resource.getrusage(RUSAGE_SELF).ru_maxrss&lt;/code&gt;, not average usage. Peak is what gets your process killed.&lt;/p&gt;

&lt;p&gt;The other trick is resolution discipline. The cover only needs to be 1280 pixels wide, so there is no reason to touch a 300 dpi source. The script downsamples previews before compositing. Inspecting at the resolution you need is the single easiest memory win in this whole pipeline.&lt;/p&gt;

&lt;h2&gt;
  
  
  The zip is half the product
&lt;/h2&gt;

&lt;p&gt;The cover gets the click. The zip gets the refund rate. &lt;code&gt;pack_product.py&lt;/code&gt; bundles the actual files with a sensible internal structure, so a buyer unzips into something that looks organized instead of forty loose files. Same reason &lt;code&gt;md2pdf.py&lt;/code&gt; exists next to it: content that looks like a product sells like a product. A bare file does not.&lt;/p&gt;

&lt;p&gt;Both scripts are deliberately separate. Packaging and document rendering fail in different ways, and when one breaks at 2 a.m. I want to know which one in five seconds.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where this sits in the pipeline
&lt;/h2&gt;

&lt;p&gt;The four scripts in the starter kit split cleanly by dependency:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;make_metadata.py&lt;/code&gt; writes the exact upload CSVs: Adobe's 5-column header, Vecteezy's 4-column, Dreamstime's 15-column. All three channels reject a wrong header instantly, so the script writes the header they demand and nothing else.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;upload_tracker.py&lt;/code&gt; is a ledger so a re-run never double-posts. Double-posting a batch is the fastest way to get a stock account flagged.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;md2pdf.py&lt;/code&gt; needs reportlab. Everything else needs Pillow or nothing. &lt;code&gt;make_metadata.py&lt;/code&gt; and &lt;code&gt;upload_tracker.py&lt;/code&gt; run on the standard library alone, no install.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The numbers, honestly
&lt;/h2&gt;

&lt;p&gt;This is the same pipeline that packaged 39 products, 856 MB of output, from about 19,000 lines of parent-project code and 300+ pages of research behind the rules the scripts enforce. On the product side: 1,860 assets made, 1,077 ready or submitted. The first paid sale was for the Digital Asset Factory itself, $2.80. I am not going to pretend that number is anything but a start, but it is a real number, from a system that ran on a server that costs almost nothing.&lt;/p&gt;

&lt;p&gt;If you want the same four scripts, the folder layout that keeps a catalogue from turning into a junk drawer, and the RAM notes for running it on a 1-core box, that is the Digital Asset Factory starter kit. It is the tool I built out of this work, not a course.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://revanzart-shop.revanzart.workers.dev/go/digital-asset-factory?src=devto" rel="noopener noreferrer"&gt;Get digital asset factory&lt;/a&gt;&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Building digital products? &lt;a href="https://revanzart-shop.revanzart.workers.dev/guides" rel="noopener noreferrer"&gt;Free guides &amp;amp; kits for digital product sellers&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>sideprojects</category>
      <category>ai</category>
      <category>automation</category>
      <category>marketing</category>
    </item>
    <item>
      <title>Ã 720 or 600Ã 600: What Each Size Does in pack_product.py and Why</title>
      <dc:creator>Muhamad Revanza Rafif Hamdani</dc:creator>
      <pubDate>Tue, 10 Nov 2026 12:42:27 +0000</pubDate>
      <link>https://dev.to/revanzart/a-720-or-600a-600-what-each-size-does-in-packproductpy-and-why-54a3</link>
      <guid>https://dev.to/revanzart/a-720-or-600a-600-what-each-size-does-in-packproductpy-and-why-54a3</guid>
      <description>&lt;p&gt;I spent an evening staring at a marketplace listing that looked fine on my laptop and terrible in the store grid. The cover I had uploaded was a square crop of a wide image, so the title text was half gone. The thumbnail in search results was blurry because the platform upscaled it. That was the moment I stopped treating cover images as an afterthought and wrote the sizing logic properly into &lt;code&gt;pack_product.py&lt;/code&gt;. Here is what the two sizes actually do and why both exist.&lt;/p&gt;

&lt;h2&gt;
  
  
  What pack_product.py produces
&lt;/h2&gt;

&lt;p&gt;The script takes a product folder, a title, a subtitle, and an asset count, and writes three things: the zip of files, a cover, and a thumbnail. The command looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;python pack_product.py my-product &lt;span class="s2"&gt;"My Product"&lt;/span&gt; &lt;span class="s2"&gt;"A short subtitle"&lt;/span&gt; 9
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The cover is rendered at 1280×720. The thumbnail is rendered at 600×600. They are not two exports of the same layout. They are two different layouts, drawn separately, because they get used in different places with different aspect requirements.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why the cover is 1280×720
&lt;/h2&gt;

&lt;p&gt;The 16:9 ratio matches how product pages and listing previews display wide imagery. A 1280×720 canvas gives me room for a title on one or two lines, a subtitle underneath, and a small count badge like "9 assets" in a corner. At 720 pixels of height, text set at 40 to 48 points in Pillow stays crisp after marketplace compression. Below that, JPEG artifacts start eating the edges of letterforms, and a cover with fuzzy text reads as low effort, which kills conversion before anyone opens the zip.&lt;/p&gt;

&lt;p&gt;The wide format also matches how I show products on my own pages. One render, two uses. When I tried square covers early on, wide slots cropped them and I lost the title every time.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why the thumbnail is 600×600
&lt;/h2&gt;

&lt;p&gt;Search grids and category pages are square. That is just how most storefronts lay things out. A 600×600 thumbnail is drawn as its own composition: the title centered, larger relative to the canvas, no subtitle, because at thumbnail size the subtitle is unreadable noise. I tested 300×300 first and it looked soft on retina screens. 600×600 holds up after platform-side downscaling and stays a small file, which matters when you are packaging 39 products and watching disk usage.&lt;/p&gt;

&lt;p&gt;The key decision is that the thumbnail is not a resize of the cover. Resizing a 1280×720 image into a square means either cropping the sides, which cuts text, or letterboxing, which looks like a placeholder. Drawing the square separately takes maybe twenty extra lines of Pillow code and removes the whole class of problems.&lt;/p&gt;

&lt;h2&gt;
  
  
  How it stays cheap on a tiny server
&lt;/h2&gt;

&lt;p&gt;The whole pipeline runs on 1 vCPU and 2 GB RAM with no GPU, so image work has rules. One process at a time, never parallel image jobs on a single core. &lt;code&gt;gc.collect()&lt;/code&gt; after each product. Pillow rendering two images at these sizes uses well under 100 MB of peak RSS, which I check with &lt;code&gt;resource.getrusage(RUSAGE_SELF).ru_maxrss&lt;/code&gt; rather than guessing. The heavy trick is rendering text at the final resolution directly instead of rendering large and scaling down, which would spike memory for no visual gain.&lt;/p&gt;

&lt;p&gt;Output goes to a &lt;code&gt;_system&lt;/code&gt; folder next to where you run the script, or wherever &lt;code&gt;--dir&lt;/code&gt; or the &lt;code&gt;PACK_ROOT&lt;/code&gt; environment variable points. That keeps generated covers out of your &lt;code&gt;packs/&lt;/code&gt; folder so the folder layout stays clean: &lt;code&gt;assets/&lt;/code&gt; for raw output, &lt;code&gt;output/&lt;/code&gt; for ready-to-submit files, &lt;code&gt;packs/&lt;/code&gt; for finished zips and covers.&lt;/p&gt;

&lt;h2&gt;
  
  
  The part that actually sold something
&lt;/h2&gt;

&lt;p&gt;The first paid sale in this project was for the Digital Asset Factory itself, for $2.80. Small number, real signal. The listing used exactly this pairing: a 1280×720 cover for the product page and a 600×600 thumbnail for the grid. I cannot prove the covers caused the sale, but I can prove the earlier listings with cropped square covers got zero. Presentation is the only variable I changed.&lt;/p&gt;

&lt;p&gt;The numbers behind the whole operation: 1,860 assets made, 1,077 ready or submitted, 39 products packaged, 856 MB of output, all on that same 1 vCPU, 2 GB box. Packaging was the boring step that made the difference between a folder of files and things a stranger could buy.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I would tell my past self
&lt;/h2&gt;

&lt;p&gt;Pick your two render sizes once, hardcode them, and draw each one as its own layout. Do not crop, do not upscale, do not reuse one image for both slots. Text at 600 pixels wide needs to be big and short. Text at 1280 pixels wide can breathe. And test on the actual marketplace, not your file manager, because the grid view is where buyers first see your work.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;pack_product.py&lt;/code&gt; is one of four scripts in the kit I built from this work. The others handle the parts that stalled me even more: &lt;code&gt;make_metadata.py&lt;/code&gt; writes the exact Adobe 5-column, Vecteezy 4-column, and Dreamstime 15-column CSV headers so uploads do not bounce, &lt;code&gt;upload_tracker.py&lt;/code&gt; prevents double-posting re-runs, and &lt;code&gt;md2pdf.py&lt;/code&gt; turns a Markdown doc into a styled PDF with reportlab. Two third-party packages total, Pillow and reportlab, everything else is standard library. It all ran on the tiny server described above.&lt;/p&gt;

&lt;p&gt;If you want the same scripts instead of rebuilding the sizing and metadata logic from scratch, the full system is here:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://revanzart-shop.revanzart.workers.dev/go/digital-asset-factory?src=devto" rel="noopener noreferrer"&gt;Get digital asset factory&lt;/a&gt;&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Building digital products? &lt;a href="https://revanzart-shop.revanzart.workers.dev/guides" rel="noopener noreferrer"&gt;Free guides &amp;amp; kits for digital product sellers&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>sideprojects</category>
      <category>ai</category>
      <category>automation</category>
      <category>marketing</category>
    </item>
    <item>
      <title># 3 dev.to Article Titles</title>
      <dc:creator>Muhamad Revanza Rafif Hamdani</dc:creator>
      <pubDate>Tue, 10 Nov 2026 12:42:15 +0000</pubDate>
      <link>https://dev.to/revanzart/-3-devto-article-titles-29l8</link>
      <guid>https://dev.to/revanzart/-3-devto-article-titles-29l8</guid>
      <description>&lt;h1&gt;
  
  
  3 dev.to Article Titles
&lt;/h1&gt;

&lt;p&gt;Wait, no. This post is not a listicle about titles. It is the post about the moment my upload batch got rejected twice in one morning, and the four scripts I wrote so it never happens again. The title above is what I almost published instead, and deleting it taught me more than writing it did.&lt;/p&gt;

&lt;p&gt;Here is the real story.&lt;/p&gt;

&lt;h2&gt;
  
  
  The morning Adobe rejected 40 CSVs
&lt;/h2&gt;

&lt;p&gt;Last spring I had 40 finished JPGs sitting in a folder, ready to go. I built the upload CSV by hand in a spreadsheet, exported it, uploaded it, and got an instant rejection. Wrong column count. Adobe wants 5 columns. I fixed it, re-exported, tried Vecteezy, and got rejected again because Vecteezy wants 4 columns, not 5. Dreamstime wants 15. Three platforms, three headers, and every one of them rejects the file instantly without telling you which column is wrong.&lt;/p&gt;

&lt;p&gt;That was the moment I stopped hand-building CSVs and wrote &lt;code&gt;make_metadata.py&lt;/code&gt;. It writes the exact header each platform demands and nothing else. One command per platform:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;python make_metadata.py ./my-assets --platform adobe   --out metadata.csv --ext .jpg
python make_metadata.py ./my-assets --platform vecteezy --out vecteezy.csv --ext .jpg
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Without a titles file it title-cases the filename, which is fine for a first pass but you should replace it before submitting. The point is the header is never wrong again.&lt;/p&gt;

&lt;h2&gt;
  
  
  The second rejection was worse: double-posting
&lt;/h2&gt;

&lt;p&gt;Two weeks later I re-ran a batch script, forgot I had already submitted half of it, and double-posted. Duplicate submissions are one of the fastest ways to get a stock account flagged. Nothing about the rejection message says that, you just notice your acceptance rate drop.&lt;/p&gt;

&lt;p&gt;So I wrote &lt;code&gt;upload_tracker.py&lt;/code&gt;, a plain ledger that remembers what is done:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;python upload_tracker.py pending items.txt
python upload_tracker.py &lt;span class="k"&gt;done&lt;/span&gt; &lt;span class="s2"&gt;"asset-001.jpg"&lt;/span&gt;
python upload_tracker.py status
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A re-run can no longer double-post because the tracker makes it impossible, not just unlikely. This uses the Python standard library alone, no install at all.&lt;/p&gt;

&lt;h2&gt;
  
  
  The packaging problem nobody warns you about
&lt;/h2&gt;

&lt;p&gt;Making the art is the fun part. Selling it is four boring problems, and I hit all of them:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Metadata, solved above.&lt;/li&gt;
&lt;li&gt;Double-posting, solved above.&lt;/li&gt;
&lt;li&gt;Turning a document into something that looks like a product. I had Markdown content and needed a clean PDF with headings, tables, lists, and links. &lt;code&gt;md2pdf.py&lt;/code&gt; does it in one command, using reportlab, one of only two third-party packages in the whole kit.&lt;/li&gt;
&lt;li&gt;Packaging. A zip with a real cover sells. A bare file does not. &lt;code&gt;pack_product.py&lt;/code&gt; bundles the files and renders a cover at 1280x720 and a thumbnail at 600x600 using Pillow.
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;python pack_product.py my-product &lt;span class="s2"&gt;"My Product"&lt;/span&gt; &lt;span class="s2"&gt;"A short subtitle"&lt;/span&gt; 9
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That writes the zip, the cover, and the thumb. Covers and thumbnails at the right dimensions matter because marketplace listings crop or reject anything else.&lt;/p&gt;

&lt;h2&gt;
  
  
  The tiny server habits that made it possible
&lt;/h2&gt;

&lt;p&gt;This whole pipeline ran on 1 vCPU, 2 GB RAM, no GPU. It works because of habits, not hardware:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;One process at a time. Never fan out image jobs in parallel on a 1-core box.&lt;/li&gt;
&lt;li&gt;Call &lt;code&gt;gc.collect()&lt;/code&gt; after each asset.&lt;/li&gt;
&lt;li&gt;Measure peak RSS with &lt;code&gt;resource.getrusage(RUSAGE_SELF).ru_maxrss&lt;/code&gt;, not average memory. Average lies, the ceiling is what kills you.&lt;/li&gt;
&lt;li&gt;One file per subprocess for heavy render steps so memory returns to the OS.&lt;/li&gt;
&lt;li&gt;Inspect previews at 60 dpi, render at 300 only when it is final.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;AI image generation costs add up fast when you regenerate because of a packaging mistake, so getting packaging right the first time is also a cost decision.&lt;/p&gt;

&lt;h2&gt;
  
  
  The folder layout that saved me from a junk drawer
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;project/
  assets/      raw output, one file per asset
  output/      ready-to-submit files + metadata.csv
  packs/       finished zips + covers
  research/    notes and sources
  scripts/     the tools
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Keep &lt;code&gt;research/&lt;/code&gt; forever. I deleted mine once to "clean up" and lost the raw material for three future products. I will not repeat that.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where this stands now
&lt;/h2&gt;

&lt;p&gt;The kit contains the exact scripts that packaged 39 products, about 856 MB of output, from a parent project with roughly 19,000 lines of pipeline code and 300+ pages of research behind the rules the scripts enforce. So far 1,860 assets have gone through the pipeline and 1,077 are ready or submitted. The first paid sale from this whole system was $2.80, and it was for the system itself, the Digital Asset Factory. I am not going to pretend that number is anything but a start. But it is real money from a pipeline that runs on a 2 GB server, and every part of it is documented.&lt;/p&gt;

&lt;p&gt;The Digital Asset Factory is the full system built from this work, the four scripts here are just the packaging layer, sitting under a free orchestration stack. If you want the whole thing instead of the four tools, that is where it lives.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://revanzart-shop.revanzart.workers.dev/go/digital-asset-factory?src=devto" rel="noopener noreferrer"&gt;Get digital asset factory&lt;/a&gt;&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Building digital products? &lt;a href="https://revanzart-shop.revanzart.workers.dev/guides" rel="noopener noreferrer"&gt;Free guides &amp;amp; kits for digital product sellers&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>sideprojects</category>
      <category>ai</category>
      <category>automation</category>
      <category>marketing</category>
    </item>
    <item>
      <title>Products, One Core, 2GB of RAM: How Upload Rejections Became My Most Reliable Build Step**</title>
      <dc:creator>Muhamad Revanza Rafif Hamdani</dc:creator>
      <pubDate>Mon, 09 Nov 2026 23:12:43 +0000</pubDate>
      <link>https://dev.to/revanzart/products-one-core-2gb-of-ram-how-upload-rejections-became-my-most-reliable-build-step-5649</link>
      <guid>https://dev.to/revanzart/products-one-core-2gb-of-ram-how-upload-rejections-became-my-most-reliable-build-step-5649</guid>
      <description>&lt;p&gt;First Adobe Stock batch I ever sent, 140 files, gone in under a minute. No review, no email, just red. Header row was wrong. I had built the CSV with the Vecteezy column set and pointed Adobe at it, because both files lived in the same &lt;code&gt;output/&lt;/code&gt; folder and I grabbed the wrong one. Adobe wants 5 columns. Vecteezy wants 4. Dreamstime wants 15. A mismatched header gets rejected before any human or model looks at the art.&lt;/p&gt;

&lt;p&gt;That was the moment I stopped treating upload as the last step and started treating it as a build step. Same as a compiler. You do not ship a binary and hope. You run the check that fails loud.&lt;/p&gt;

&lt;p&gt;Everything below is from one pipeline: 1,860 assets made, 1,077 ready or submitted, 39 finished products, 856 MB of output, all on 1 vCPU, 2 GB RAM, no GPU.&lt;/p&gt;

&lt;h2&gt;
  
  
  Metadata headers are a schema, not paperwork
&lt;/h2&gt;

&lt;p&gt;The rejections were not about my images. They were about the header row. Each channel demands an exact column set and rejects the whole file on a mismatch.&lt;/p&gt;

&lt;p&gt;Adobe: 5 columns. Vecteezy: 4 columns. Dreamstime: 15 columns. Get one label wrong, one column short, one column reordered, and the upload fails instantly. Same as a JSON payload missing a required key.&lt;/p&gt;

&lt;p&gt;So &lt;code&gt;make_metadata.py&lt;/code&gt; writes the header the platform demands and nothing else. No extra columns for my own bookkeeping, no guessing. One command per platform:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;python make_metadata.py ./my-assets --platform adobe    --out metadata.csv   --ext .jpg
python make_metadata.py ./my-assets --platform vecteezy --out vecteezy.csv   --ext .jpg
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;By default it title-cases the filename and uses that as the title. That is a first pass, fine for local testing, and you should replace it before you submit with real titles and keywords via &lt;code&gt;--titles titles.json&lt;/code&gt;. I am being blunt about that because filename titles do get noticed and they do get rejected on quality review. The script gets the schema right; it cannot write your description for you.&lt;/p&gt;

&lt;p&gt;The payoff is boring and real: headers stop being a source of rejection. You move the failure earlier, where you can fix it in seconds instead of losing a batch.&lt;/p&gt;

&lt;h2&gt;
  
  
  The ledger that makes double-posting impossible
&lt;/h2&gt;

&lt;p&gt;Second mistake I made: running a batch twice. First run uploaded fine, re-run duplicated 90 rows because I had re-joined the folder listing and lost track of what was already done.&lt;/p&gt;

&lt;p&gt;Flagging risk on a stock account is the kind of thing you do not get to undo. So &lt;code&gt;upload_tracker.py&lt;/code&gt; keeps a plain ledger and answers one question: what is not done yet?&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;python upload_tracker.py pending items.txt
python upload_tracker.py &lt;span class="k"&gt;done&lt;/span&gt; &lt;span class="s2"&gt;"asset-001.jpg"&lt;/span&gt;
python upload_tracker.py status
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;pending&lt;/code&gt; prints only the files with no &lt;code&gt;done&lt;/code&gt; entry. The upload loop reads that list, changes one file, marks it done. Re-running it is safe by construction. The ledger is the source of truth, not my memory. Standard library only, no install.&lt;/p&gt;

&lt;h2&gt;
  
  
  One core, 2 GB, no GPU, and it still ships
&lt;/h2&gt;

&lt;p&gt;The whole pipeline ran on 1 vCPU with 2 GB RAM. Not a container with a soft limit, an actual tiny box. It works because of a few habits, and none of them are clever.&lt;/p&gt;

&lt;p&gt;One process at a time. Never fan out image work in parallel on a single core. Parallel jobs on one core do not go faster, they just fight for the same cycles and peak memory at the same time.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;gc.collect()&lt;/code&gt; after each asset. Cheap insurance when you are rendering hundreds of files in a loop.&lt;/p&gt;

&lt;p&gt;Measure peak RSS, not average. &lt;code&gt;resource.getrusage(RUSAGE_SELF).ru_maxrss&lt;/code&gt; reports the real ceiling. Average memory tells you nothing about the render that almost killed the box.&lt;/p&gt;

&lt;p&gt;One file per subprocess for heavy render steps, so memory returns to the OS when the child exits instead of lingering in the parent.&lt;/p&gt;

&lt;p&gt;Render at the resolution you need. Inspecting a preview at 60 dpi beats rendering it at 300 dpi just to look at it. The 300 dpi pass is the one you ship, not the one you debug with.&lt;/p&gt;

&lt;p&gt;These are not optimizations. They are the difference between a working job and an OOM kill at item 700.&lt;/p&gt;

&lt;h2&gt;
  
  
  Every product is a zip plus a cover
&lt;/h2&gt;

&lt;p&gt;A bare file does not sell. A zip with a real cover does. &lt;code&gt;pack_product.py&lt;/code&gt; does both in one step: zips the files, renders a cover at 1280x720 and a thumbnail at 600x600, and drops all three into a &lt;code&gt;_system&lt;/code&gt; folder. Point it elsewhere with &lt;code&gt;--dir&lt;/code&gt; or the &lt;code&gt;PACK_ROOT&lt;/code&gt; environment variable, no script edits.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;python pack_product.py my-product &lt;span class="s2"&gt;"My Product"&lt;/span&gt; &lt;span class="s2"&gt;"A short subtitle"&lt;/span&gt; 9
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Turn a Markdown doc into a product with &lt;code&gt;md2pdf.py&lt;/code&gt;, which handles headings, tables, lists and links and outputs a styled PDF. That is how a research file becomes something you can list.&lt;/p&gt;

&lt;p&gt;Two third-party packages across the whole kit: Pillow for covers, reportlab for PDF. If you only use metadata and the tracker, you install nothing, they are pure standard library.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the numbers actually look like
&lt;/h2&gt;

&lt;p&gt;1,860 assets generated. 1,077 ready or submitted. 39 products packaged. About 19,000 lines of pipeline code in the parent project, and 300+ pages of research behind the rules the scripts enforce.&lt;/p&gt;

&lt;p&gt;My first paid sale was this system: $2.80. Not a milestone worth a screenshot. Worth mentioning because it is honest. Small numbers, real ones.&lt;/p&gt;

&lt;p&gt;The lesson I keep coming back to: the rejections were the cheapest feedback in the whole process. Each one pointed at a rule I could encode. Once encoded, it stopped costing me batches. That is all a build step is.&lt;/p&gt;

&lt;p&gt;The four scripts are the packaging layer of what I now call Digital Asset Factory: metadata generator, upload tracker, Markdown to PDF, product packer, plus the folder layout and the tiny-server habits that keep it alive. If you are building the same thing, it is the version of this work that already made the mistakes.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://revanzart-shop.revanzart.workers.dev/go/digital-asset-factory?src=devto" rel="noopener noreferrer"&gt;Get digital asset factory&lt;/a&gt;&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Building digital products? &lt;a href="https://revanzart-shop.revanzart.workers.dev/guides" rel="noopener noreferrer"&gt;Free guides &amp;amp; kits for digital product sellers&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>sideprojects</category>
      <category>ai</category>
      <category>automation</category>
      <category>marketing</category>
    </item>
    <item>
      <title>Your Batch Job Posted Twice and Got the Account Flagged — Here's the Tracker That Prevents It**</title>
      <dc:creator>Muhamad Revanza Rafif Hamdani</dc:creator>
      <pubDate>Mon, 09 Nov 2026 23:12:31 +0000</pubDate>
      <link>https://dev.to/revanzart/your-batch-job-posted-twice-and-got-the-account-flagged-heres-the-tracker-that-prevents-it-3ojf</link>
      <guid>https://dev.to/revanzart/your-batch-job-posted-twice-and-got-the-account-flagged-heres-the-tracker-that-prevents-it-3ojf</guid>
      <description>&lt;p&gt;I posted the same 212 stickers twice.&lt;/p&gt;

&lt;p&gt;Not on purpose. My batch script died halfway through the upload step, I fixed the error, and I re-ran the job from the top because the script had no idea what it had already sent. There was no tracker yet. There was a folder of files and a loop that assumed a clean slate every time.&lt;/p&gt;

&lt;p&gt;Two days later the account was flagged, uploads were locked, and a support ticket asked me to explain the duplicates. Nothing malicious had happened. I had built a pipeline with no memory.&lt;/p&gt;

&lt;p&gt;That is when I stopped treating the tracker as bookkeeping and started treating it as the safety rail for everything else.&lt;/p&gt;

&lt;h2&gt;
  
  
  The bug was state, not art
&lt;/h2&gt;

&lt;p&gt;The art was fine. The metadata was fine. What broke was that my pipeline could not answer one question: what have I already submitted?&lt;/p&gt;

&lt;p&gt;Every batch tool I had written assumed it was running for the first time. Re-run it and it reposts. Fix a bug mid-run and it reposts. Change one title and it reposts the other 211 files too, because nothing recorded which ones were already done.&lt;/p&gt;

&lt;p&gt;The fix is not clever. It is a plain text ledger, one line per asset, plus three commands.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;python upload_tracker.py pending items.txt      &lt;span class="c"&gt;# what is NOT done yet&lt;/span&gt;
python upload_tracker.py &lt;span class="k"&gt;done&lt;/span&gt; &lt;span class="s2"&gt;"asset-001.jpg"&lt;/span&gt;   &lt;span class="c"&gt;# mark one as sent&lt;/span&gt;
python upload_tracker.py status                 &lt;span class="c"&gt;# counts, totals&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is it. &lt;code&gt;upload_tracker.py&lt;/code&gt; uses the standard library alone. No sqlite, no database, no service. For a catalogue in the low thousands, a text file is the correct data store and I skipped the schema for a reason.&lt;/p&gt;

&lt;p&gt;The rule I run now: &lt;code&gt;pending&lt;/code&gt; before every batch, never after. If &lt;code&gt;pending&lt;/code&gt; does not match what I expect to send, the batch does not start. That single check would have saved me the flagged account.&lt;/p&gt;

&lt;p&gt;One honest limit: the tracker prevents double-posting from my side. It does not fix a platform that already has your file. It is a local guard, not a dedupe service.&lt;/p&gt;

&lt;h2&gt;
  
  
  Headers are the other place batches die
&lt;/h2&gt;

&lt;p&gt;Duplicate submissions get you flagged. Bad CSV headers get you rejected at ingest, before a human ever sees the work.&lt;/p&gt;

&lt;p&gt;Three channels, three different headers. Adobe takes a 5 column metadata CSV. Vecteezy takes 4 columns. Dreamstime takes 15. Get one column wrong and the whole import fails, usually with a message that tells you nothing useful.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;make_metadata.py&lt;/code&gt; writes exactly the header each channel demands and nothing else:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;python make_metadata.py ./my-assets --platform adobe    --out metadata.csv --ext .jpg
python make_metadata.py ./my-assets --platform vecteezy --out vecteezy.csv --ext .jpg
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It also takes &lt;code&gt;--titles titles.json&lt;/code&gt; if you have real titles and keywords. Without that flag it title-cases the filename and uses it as the title. That is a first pass, not a submission. Replace it before you send anything.&lt;/p&gt;

&lt;h2&gt;
  
  
  What a 1 vCPU box taught me
&lt;/h2&gt;

&lt;p&gt;The whole pipeline ran on 1 vCPU, 2 GB RAM, no GPU. That constraint is not a limitation to work around later, it is the reason the code stays simple. Five habits got me through 1,860 assets without an out of memory kill:&lt;/p&gt;

&lt;p&gt;One process at a time. Never fan out image jobs in parallel on a single core, you just trade CPU time for swap.&lt;/p&gt;

&lt;p&gt;Free memory between items. &lt;code&gt;gc.collect()&lt;/code&gt; after each asset, not once at the end.&lt;/p&gt;

&lt;p&gt;Measure peak, not average. &lt;code&gt;resource.getrusage(RUSAGE_SELF).ru_maxrss&lt;/code&gt; gives the real ceiling. Average RSS will tell you everything is fine right up until the process dies.&lt;/p&gt;

&lt;p&gt;One file per subprocess for heavy renders, so memory goes back to the OS instead of sitting in a pool.&lt;/p&gt;

&lt;p&gt;Render at the size you need. Inspecting a preview at 60 dpi beats rendering it at 300 and waiting.&lt;/p&gt;

&lt;h2&gt;
  
  
  Packaging is part of the batch, not an afterthought
&lt;/h2&gt;

&lt;p&gt;A zip named &lt;code&gt;product.zip&lt;/code&gt; with no image sells nothing. The batch output needs a cover at 1280 by 720 and a thumbnail at 600 by 600, generated in the same run as the zip, or it never gets made.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;pack_product.py&lt;/code&gt; does all three in one command and writes into a &lt;code&gt;_system&lt;/code&gt; folder next to wherever you run it. Point it elsewhere with &lt;code&gt;--dir&lt;/code&gt; or the &lt;code&gt;PACK_ROOT&lt;/code&gt; environment variable. Only third party dependency in that script is Pillow.&lt;/p&gt;

&lt;p&gt;The folder layout that survived 39 packaged products:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;project/
  assets/     raw output
  output/     ready to submit files + metadata.csv
  packs/      finished zips, covers, thumbs
  research/   notes and sources
  scripts/    the tools
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Keep &lt;code&gt;research/&lt;/code&gt;. I deleted mine once to clean up and spent a week rebuilding sources I had already read.&lt;/p&gt;

&lt;h2&gt;
  
  
  The numbers, plainly
&lt;/h2&gt;

&lt;p&gt;1,860 assets made. 1,077 ready or submitted. 39 products packaged, 856 MB of output. Roughly 19,000 lines of pipeline code in the parent project, backed by 300 or more pages of research on the rules these scripts enforce. All of it validated on 1 vCPU, 2 GB RAM, no GPU.&lt;/p&gt;

&lt;p&gt;First paid sale: Digital Asset Factory, $2.80.&lt;/p&gt;

&lt;p&gt;$2.80 is not a milestone, it is a calibration. It confirmed the checkout works and the delivery works. The rest is volume, and volume only happens if the batch can be re-run safely, which is where this post started.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where the tracker lives
&lt;/h2&gt;

&lt;p&gt;The four scripts are the packaging and safety layer. &lt;code&gt;make_metadata.py&lt;/code&gt; and &lt;code&gt;upload_tracker.py&lt;/code&gt; need nothing but Python. &lt;code&gt;md2pdf.py&lt;/code&gt; and &lt;code&gt;pack_product.py&lt;/code&gt; pull in reportlab and Pillow, and that is the entire dependency list.&lt;/p&gt;

&lt;p&gt;If your only problem right now is double-posting, the tracker alone will fix it and you do not need anything else from me.&lt;/p&gt;

&lt;p&gt;The full Digital Asset Factory is the wider system these scripts sit inside: the orchestration, the research notes, the metadata rules for all three channels, and the packaging layer. I built it after the flagged account, so nobody else has to learn that lesson the way I did.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://revanzart-shop.revanzart.workers.dev/go/digital-asset-factory?src=devto" rel="noopener noreferrer"&gt;Get digital asset factory&lt;/a&gt;&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Building digital products? &lt;a href="https://revanzart-shop.revanzart.workers.dev/guides" rel="noopener noreferrer"&gt;Free guides &amp;amp; kits for digital product sellers&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>sideprojects</category>
      <category>ai</category>
      <category>automation</category>
      <category>marketing</category>
    </item>
    <item>
      <title>From Fun Art to Finished Product: The Four Anti-Stall Scripts That Closed 36 of 39 Gaps**</title>
      <dc:creator>Muhamad Revanza Rafif Hamdani</dc:creator>
      <pubDate>Mon, 09 Nov 2026 17:12:41 +0000</pubDate>
      <link>https://dev.to/revanzart/from-fun-art-to-finished-product-the-four-anti-stall-scripts-that-closed-36-of-39-gaps-5mm</link>
      <guid>https://dev.to/revanzart/from-fun-art-to-finished-product-the-four-anti-stall-scripts-that-closed-36-of-39-gaps-5mm</guid>
      <description>&lt;p&gt;I still have the screenshot of the rejection. Adobe Stock bounced a batch of 12 vectors because my CSV header had five columns and I'd given it four. No explanation, just a red error at the top of the upload page. I'd spent two days generating the art. The upload took ninety seconds to fail.&lt;/p&gt;

&lt;p&gt;That was the moment I stopped treating my pipeline like a hobby and started treating it like a register. Every failure got written down. Over the following weeks I logged 39 distinct gaps between "I made art" and "this is a sellable product", and I closed 36 of them with four boring Python scripts. Here is what actually worked.&lt;/p&gt;

&lt;h2&gt;
  
  
  Metadata is a formatting test, not a creative one
&lt;/h2&gt;

&lt;p&gt;The stock platforms do not read your CSVs the way a human does. Adobe expects a five column header. Vecteezy expects four. Dreamstime wants fifteen. Get one column wrong and the file is rejected before anyone looks at your art.&lt;/p&gt;

&lt;p&gt;So the first script I wrote, &lt;code&gt;make_metadata.py&lt;/code&gt;, does exactly one thing: it takes a folder of files and writes the exact header each platform demands, nothing else. It runs on the standard library alone, no pip install. You point it at a folder:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;python make_metadata.py ./my-assets &lt;span class="nt"&gt;--platform&lt;/span&gt; adobe &lt;span class="nt"&gt;--out&lt;/span&gt; metadata.csv &lt;span class="nt"&gt;--ext&lt;/span&gt; .jpg
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Without a titles file it title-cases the filename, which is fine for a first pass and not fine for submission. I learned that the hard way too: assets titled after their filenames get through review less often than assets with real titles and keywords. The optional &lt;code&gt;--titles titles.json&lt;/code&gt; flag exists because of that.&lt;/p&gt;

&lt;h2&gt;
  
  
  Double-posting is the fastest way to get flagged
&lt;/h2&gt;

&lt;p&gt;My second real failure: I re-ran a batch upload after a timeout, posted the same 20 files twice, and spent an afternoon deleting duplicates while wondering if the account would survive it. Stock platforms are not forgiving about this.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;upload_tracker.py&lt;/code&gt; is a ledger, also pure standard library. Three commands:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;python upload_tracker.py pending items.txt
python upload_tracker.py &lt;span class="k"&gt;done&lt;/span&gt; &lt;span class="s2"&gt;"asset-001.jpg"&lt;/span&gt;
python upload_tracker.py status
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Pending shows only what is not done yet. A re-run can never double-post because the ledger is checked before anything is queued. It is the least interesting script in the kit and the one I would least want to lose.&lt;/p&gt;

&lt;h2&gt;
  
  
  A document is not a product until it looks like one
&lt;/h2&gt;

&lt;p&gt;I had content sitting in Markdown files for weeks before I could sell any of it, because turning Markdown into a presentable PDF felt like a separate project. It is not. &lt;code&gt;md2pdf.py&lt;/code&gt; uses reportlab, one of only two third-party packages in the whole kit, and does headings, tables, lists and links in one command:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;python md2pdf.py content.md product.pdf &lt;span class="s2"&gt;"My Product Title"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The other package is Pillow, used by &lt;code&gt;pack_product.py&lt;/code&gt; to render a 1280x720 cover and a 600x600 thumbnail. That script bundles the zip and writes both images into a &lt;code&gt;_system&lt;/code&gt; folder, and you can redirect it with &lt;code&gt;--dir&lt;/code&gt; or a &lt;code&gt;PACK_ROOT&lt;/code&gt; environment variable instead of editing anything. A zip with a real cover sells. A bare file does not. I do not have A/B numbers to prove that, but every product I packaged properly moved, and the bare ones did not.&lt;/p&gt;

&lt;h2&gt;
  
  
  The whole thing ran on 1 vCPU and 2 GB of RAM
&lt;/h2&gt;

&lt;p&gt;No GPU, no cloud account. That constraint shaped the pipeline more than any platform rule did. The habits that made it work:&lt;/p&gt;

&lt;p&gt;One process at a time. Fanning out image jobs in parallel on a single core just swaps memory and slows everything down. Call &lt;code&gt;gc.collect()&lt;/code&gt; after each asset. Measure peak RSS with &lt;code&gt;resource.getrusage(RUSAGE_SELF).ru_maxrss&lt;/code&gt;, not average usage, because the peak is what kills a 2 GB box. Run heavy render steps one file per subprocess so memory actually returns to the OS. And render at the resolution you need: inspecting a preview at 60 dpi instead of 300 saves more time than any other single change I made.&lt;/p&gt;

&lt;h2&gt;
  
  
  The folder layout is part of the system
&lt;/h2&gt;

&lt;p&gt;A catalogue becomes a junk drawer without a fixed structure. The kit assumes five folders: &lt;code&gt;assets/&lt;/code&gt; for raw output, &lt;code&gt;output/&lt;/code&gt; for ready-to-submit files plus the metadata CSV, &lt;code&gt;packs/&lt;/code&gt; for finished zips and covers, &lt;code&gt;research/&lt;/code&gt; for notes and sources, &lt;code&gt;scripts/&lt;/code&gt; for the tools.&lt;/p&gt;

&lt;p&gt;The one I would underline is &lt;code&gt;research/&lt;/code&gt;. I once deleted scrape logs and notes to "clean up", and later wanted the exact sources behind a product I had already shipped. Keep it forever. Those notes are raw material for the next product.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where the numbers landed
&lt;/h2&gt;

&lt;p&gt;The four scripts packaged 39 products, 856 MB of output, and closed 36 of the 39 gaps in my register. The parent pipeline is around 19,000 lines of code, the rules the scripts enforce sit on top of 300+ pages of platform research, and everything was validated on that 1 vCPU, 2 GB machine. The catalogue so far: 1,860 assets made, 1,077 ready or submitted. The first paid sale was for the system itself, Digital Asset Factory, $2.80. I am not going to pretend that is a business yet. It is proof the pipeline produces things people will pay for, which is the part that took the longest to believe.&lt;/p&gt;

&lt;p&gt;I am not selling a dream of passive income. I am selling the packaging layer I use every week, minus the prompt engine, because that part is personal to my catalogue. If your stall point is metadata, double-posting, PDFs or packaging, these four scripts are the honest version of the fix.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://revanzart-shop.revanzart.workers.dev/go/digital-asset-factory?src=devto" rel="noopener noreferrer"&gt;Get digital asset factory&lt;/a&gt;&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Building digital products? &lt;a href="https://revanzart-shop.revanzart.workers.dev/guides" rel="noopener noreferrer"&gt;Free guides &amp;amp; kits for digital product sellers&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>sideprojects</category>
      <category>ai</category>
      <category>automation</category>
      <category>marketing</category>
    </item>
    <item>
      <title>Shipping 39 Packaged Products with Only Pillow and reportlab: A Batch Pipeline Starter Kit**</title>
      <dc:creator>Muhamad Revanza Rafif Hamdani</dc:creator>
      <pubDate>Mon, 09 Nov 2026 17:12:27 +0000</pubDate>
      <link>https://dev.to/revanzart/shipping-39-packaged-products-with-only-pillow-and-reportlab-a-batch-pipeline-starter-kit-2ii2</link>
      <guid>https://dev.to/revanzart/shipping-39-packaged-products-with-only-pillow-and-reportlab-a-batch-pipeline-starter-kit-2ii2</guid>
      <description>&lt;p&gt;Last March I lost six hours of batch uploads because of one CSV header. I had 240 JPGs ready for Adobe Stock, generated the metadata file with a script I'd written the night before, and the uploader rejected every row. The reason was boring: I had six columns instead of five. Adobe's upload CSV takes exactly five columns in a fixed order. Vecteezy takes four. Dreamstime takes fifteen. There is no partial credit. That night I rewrote the metadata script to hard-code the headers instead of "building" them, and it has not failed since.&lt;/p&gt;

&lt;p&gt;That rewrite is now one of four scripts I want to talk about, because the metadata CSV was only the first wall I hit.&lt;/p&gt;

&lt;h2&gt;
  
  
  The four things that actually stall a solo pipeline
&lt;/h2&gt;

&lt;p&gt;Making the art is the fun part and it is maybe 20 percent of the work. What ate my weeks was the boring half:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Metadata headers.&lt;/strong&gt; All three stock channels reject a wrong CSV instantly. Adobe wants 5 columns, Vecteezy 4, Dreamstime 15. My script now writes the exact header each platform demands and nothing else. No cleverness, no extra columns, no guessing.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Double-posting.&lt;/strong&gt; Running a batch twice is the fastest way to get an account flagged. I did it once early on, re-uploading 60 files I had already submitted. Now a tiny ledger script tracks every file marked done, and a re-run only shows what is still pending.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Turning a document into a product.&lt;/strong&gt; Having content in Markdown and needing a clean PDF with headings, tables, lists, and links. One command, done.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Packaging.&lt;/strong&gt; A zip with a real cover image sells. A bare file does not. Packaging means a zip, a 1280x720 cover, and a 600x600 thumbnail, every time, without me touching an image editor.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Four scripts cover all of it. Two need third-party packages: Pillow for the covers and thumbnails, reportlab for the PDFs. The metadata builder and the upload tracker use only the Python standard library, so if those are all you need, there is nothing to install at all.&lt;/p&gt;

&lt;h2&gt;
  
  
  Running this on a 2 GB server
&lt;/h2&gt;

&lt;p&gt;The whole pipeline runs on 1 vCPU, 2 GB RAM, no GPU. That constraint shaped every script. The habits that made it work:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;One process at a time.&lt;/strong&gt; Fanning out image jobs in parallel on a single core does not speed anything up, it just pushes you into swap.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Free memory between items.&lt;/strong&gt; An explicit &lt;code&gt;gc.collect()&lt;/code&gt; after each asset keeps long batches from creeping upward.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Measure peak RSS, not average.&lt;/strong&gt; &lt;code&gt;resource.getrusage(RUSAGE_SELF).ru_maxrss&lt;/code&gt; shows the real ceiling. My average usage looked fine while one render step was spiking past what I thought the box could do.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;One file per subprocess&lt;/strong&gt; for heavy render steps, so memory is returned to the OS instead of held by a long-lived process.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Render at the resolution you need.&lt;/strong&gt; Inspecting a preview at 60 dpi instead of 300 cut one step's memory use dramatically. I only learned this after an out-of-memory kill at 2 AM.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;None of this is clever engineering. It is discipline, written into the scripts so I stop re-learning it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The folder layout that saved my catalogue
&lt;/h2&gt;

&lt;p&gt;At 39 products my project folder became a junk drawer. Files I could not tell apart, zips mixed with raw output, notes lost. The fix was a fixed structure the scripts assume:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;project/
  assets/            raw output, one file per asset
  output/            ready-to-submit files + metadata.csv
  packs/             finished product zips + covers
  research/          notes and sources
  scripts/           the tools
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One rule I learned the hard way: keep &lt;code&gt;research/&lt;/code&gt; forever. I deleted my scrape logs and notes once to "clean up", and later wanted the source material for a follow-up product. It was gone. Those notes are raw material for future products, not clutter.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the numbers look like
&lt;/h2&gt;

&lt;p&gt;The catalogue so far: 1,860 assets made, 1,077 of them ready or submitted to stock platforms, 39 finished products packaged, 856 MB of output. The parent pipeline is around 19,000 lines of Python, with 300+ pages of research behind the rules the scripts enforce. First paid sale: $2.80, for the Digital Asset Factory itself. I am not going to pretend that is a lot of money. It is proof the loop closes: make, package, list, sell.&lt;/p&gt;

&lt;p&gt;The four scripts are the packaging layer. In the full system they sit under a free stack: an orchestrator agent that schedules jobs and retries failures, a local LLM proxy, and batch image generation. But the packaging layer is where most makers stall, and it is the part that stands alone.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where to get it
&lt;/h2&gt;

&lt;p&gt;I packaged the exact scripts, not a tutorial that ends at "here's how you'd do it", into the Pipeline Starter Kit, and the full system built from this work is the Digital Asset Factory: the prompt engine, the pipeline, the packaging tools, the platform rules for Adobe, Vecteezy, and Dreamstime, and the tiny-server notes. It is the thing I wish existed when that CSV got rejected in March.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://revanzart-shop.revanzart.workers.dev/go/digital-asset-factory?src=devto" rel="noopener noreferrer"&gt;Get digital asset factory&lt;/a&gt;&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Building digital products? &lt;a href="https://revanzart-shop.revanzart.workers.dev/guides" rel="noopener noreferrer"&gt;Free guides &amp;amp; kits for digital product sellers&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>sideprojects</category>
      <category>ai</category>
      <category>automation</category>
      <category>marketing</category>
    </item>
    <item>
      <title>The Buyer Wanted Vectors, So We Shipped the 1280 720 Cover Anyway — Here's Why**</title>
      <dc:creator>Muhamad Revanza Rafif Hamdani</dc:creator>
      <pubDate>Sun, 08 Nov 2026 23:13:00 +0000</pubDate>
      <link>https://dev.to/revanzart/the-buyer-wanted-vectors-so-we-shipped-the-1280-720-cover-anyway-heres-why-4i40</link>
      <guid>https://dev.to/revanzart/the-buyer-wanted-vectors-so-we-shipped-the-1280-720-cover-anyway-heres-why-4i40</guid>
      <description>&lt;p&gt;Last Tuesday I got a support message about a product I had listed. The buyer said the listing promised vectors and asked where they were. They were right that the listing said "vector pack". They were wrong about what they had bought, because what I actually shipped was the pack with a 1280×720 cover image on top, and the cover was the thing that made the sale in the first place. That message stung, but it also settled an argument I had been having with myself for weeks: do I hold a product back until every asset is perfect, or do I package what exists and ship it?&lt;/p&gt;

&lt;p&gt;I shipped. Here is what I learned from doing it the imperfect way, and what the numbers look like.&lt;/p&gt;

&lt;h2&gt;
  
  
  The cover does more work than the assets
&lt;/h2&gt;

&lt;p&gt;When I package a product with &lt;code&gt;pack_product.py&lt;/code&gt;, it writes three things: a zip of the files, a 1280×720 cover, and a 600×600 thumbnail. The 600×600 thumbnail is what shows up in grid views and search results on most marketplaces. The 1280×720 cover is what the buyer sees when they click through.&lt;/p&gt;

&lt;p&gt;For weeks I treated those renders as an afterthought and spent my time on the assets themselves. That was backwards. A zip of files with no cover does not sell. The same files with a clean title, a subtitle, and a real cover looks like a product. Same content, different outcome, and the only difference was about forty lines of Pillow code that renders text onto a canvas.&lt;/p&gt;

&lt;p&gt;The first sale I ever made was $2.80, for the Digital Asset Factory product itself. Not a life-changing number. But it happened on a listing where the cover existed and the packaging was done, not on any of the half-finished packs sitting in my &lt;code&gt;packs/&lt;/code&gt; folder with placeholder images.&lt;/p&gt;

&lt;h2&gt;
  
  
  Metadata headers are pass or fail, not a quality score
&lt;/h2&gt;

&lt;p&gt;The other reason I stopped waiting for perfection: the stock platforms do not care about my feelings, they care about CSV headers. Adobe wants a 5-column header. Vecteezy wants 4 columns. Dreamstime wants 15. If one column is wrong or in the wrong order, the upload rejects instantly. There is no partial credit.&lt;/p&gt;

&lt;p&gt;So &lt;code&gt;make_metadata.py&lt;/code&gt; writes the exact header each platform demands and nothing else. When I generate a CSV from a folder, I know the first row will pass. What I do not know yet is whether my titles and keywords are good, which is why the script takes an optional &lt;code&gt;titles.json&lt;/code&gt;. Without it, the filename gets title-cased and used as the title. That is fine for a first pass, and I replace it before submitting. The point is that the mechanical part is solved so I can spend my judgment on the part that needs judgment.&lt;/p&gt;

&lt;h2&gt;
  
  
  Double-posting is the fastest way to get flagged
&lt;/h2&gt;

&lt;p&gt;This is the mistake that would have hurt the most. When you run a batch upload twice, the platforms see duplicate submissions, and duplicate submissions are one of the quickest ways to get an account flagged. Early on I almost re-ran a batch after a partial failure, and I realized I had no idea which files had actually gone through.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;upload_tracker.py&lt;/code&gt; is a plain ledger for this. &lt;code&gt;pending&lt;/code&gt; shows what is not done, &lt;code&gt;done "asset-001.jpg"&lt;/code&gt; marks one file, &lt;code&gt;status&lt;/code&gt; prints the summary. It uses nothing but the standard library, so there is nothing to install or break. A re-run can never double-post because the ledger is checked before anything is queued. Boring tool, and the single most important one in the kit.&lt;/p&gt;

&lt;h2&gt;
  
  
  Small servers force good habits
&lt;/h2&gt;

&lt;p&gt;The whole pipeline runs on 1 vCPU and 2 GB RAM with no GPU. That constraint turned out to be a feature. On a box like that you cannot fan out image jobs in parallel, so you run one process at a time. You call &lt;code&gt;gc.collect()&lt;/code&gt; after each asset because memory does not free itself. You measure peak RSS with &lt;code&gt;resource.getrusage(RUSAGE_SELF).ru_maxrss&lt;/code&gt; instead of guessing, because average memory tells you nothing about the ceiling. You render previews at 60 dpi for inspection instead of 300, and you render at the resolution you actually need.&lt;/p&gt;

&lt;p&gt;The result is that packaging a product takes minutes on hardware that costs almost nothing to run. 39 products packaged, 856 MB of output, on a server that would fall over if I had written it the "modern" way with parallel workers.&lt;/p&gt;

&lt;h2&gt;
  
  
  What shipping the imperfect pack actually bought me
&lt;/h2&gt;

&lt;p&gt;Back to the buyer with the vector complaint. I answered honestly, explained what was in the pack, and offered the vector files as soon as the batch finished rendering. Meanwhile the listing stayed up, because a listing that is up and 90 percent right earns more than a listing that is perfect and unpublished. Of 1,860 assets made so far, 1,077 are ready or submitted. The gap between those two numbers is not failure, it is the pipeline doing its job while I sleep.&lt;/p&gt;

&lt;p&gt;The lesson I keep relearning: the bottleneck is never the art. It is metadata, packaging, tracking, and the discipline to ship before everything is polished.&lt;/p&gt;

&lt;h2&gt;
  
  
  The tool this became
&lt;/h2&gt;

&lt;p&gt;Everything above came out of building one system, and I packaged the working parts of it as the Digital Asset Factory. It includes the four scripts I described, the folder layout, the server notes, and the metadata rules, validated on that same 1 vCPU / 2 GB box. If you want the tools instead of just the story, it is here:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://revanzart-shop.revanzart.workers.dev/go/digital-asset-factory?src=devto" rel="noopener noreferrer"&gt;Get digital asset factory&lt;/a&gt;&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Building digital products? &lt;a href="https://revanzart-shop.revanzart.workers.dev/guides" rel="noopener noreferrer"&gt;Free guides &amp;amp; kits for digital product sellers&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>sideprojects</category>
      <category>ai</category>
      <category>automation</category>
      <category>marketing</category>
    </item>
    <item>
      <title>A Single-Core 2 GB Server, Two Packages, and 39 Packaged Products: The Raster Rendering Loop That Made It Work**</title>
      <dc:creator>Muhamad Revanza Rafif Hamdani</dc:creator>
      <pubDate>Sun, 08 Nov 2026 23:12:49 +0000</pubDate>
      <link>https://dev.to/revanzart/a-single-core-2-gb-server-two-packages-and-39-packaged-products-the-raster-rendering-loop-that-5346</link>
      <guid>https://dev.to/revanzart/a-single-core-2-gb-server-two-packages-and-39-packaged-products-the-raster-rendering-loop-that-5346</guid>
      <description>&lt;p&gt;The moment that forced this loop was a rejection email from Adobe Stock. Batch of 40 JPEGs, all rejected in under an hour. I opened the CSV I had uploaded and looked at the header row. Five columns, and mine had six. That is the entire rule. Adobe's upload template is a 5-column CSV, and if the header does not match exactly, the batch dies before a human ever sees a thumbnail. I had written a metadata generator that guessed. Guessing does not survive contact with a CSV parser.&lt;/p&gt;

&lt;p&gt;That failure, and two more like it on Vecteezy and Dreamstime, is why the rendering and packaging loop I run now is boring on purpose. Here is what it looks like on a server with 1 vCPU and 2 GB of RAM, and why it has held up across 1,860 assets made and 1,077 ready or submitted.&lt;/p&gt;

&lt;h2&gt;
  
  
  The metadata headers are a contract, not a suggestion
&lt;/h2&gt;

&lt;p&gt;Every stock platform has its own CSV shape, and they do not forgive creativity:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Adobe Stock&lt;/strong&gt;: 5 columns. Filename, title, keywords, category, releases. Wrong header, instant reject.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Vecteezy&lt;/strong&gt;: 4 columns. Filename, title, keywords, plus the moderation fields the template expects.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Dreamstime&lt;/strong&gt;: 15 columns. The widest of the three, and the easiest to get subtly wrong.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;My &lt;code&gt;make_metadata.py&lt;/code&gt; script writes those three headers verbatim from a folder of files. It takes &lt;code&gt;--platform adobe&lt;/code&gt;, &lt;code&gt;--platform vecteezy&lt;/code&gt;, or &lt;code&gt;--platform dreamstime&lt;/code&gt;, and it writes the header the platform demands and nothing else. Since I stopped generating headers dynamically, my CSV rejections went to zero. The rejection reasons that still come back are content reasons, which is the only kind of rejection worth getting.&lt;/p&gt;

&lt;p&gt;One honest caveat: if you do not pass a titles file, the script title-cases the filename. That is fine for a first pass, but replace generic filenames with real titles and keywords before you submit. Filenames like &lt;code&gt;asset-001.jpg&lt;/code&gt; do not help you in search.&lt;/p&gt;

&lt;h2&gt;
  
  
  Double-posting is the fastest way to get flagged
&lt;/h2&gt;

&lt;p&gt;Early on I ran a batch twice because I lost track of what I had already uploaded. Duplicate submissions are exactly the kind of behavior that gets an account flagged. So now there is a ledger. &lt;code&gt;upload_tracker.py&lt;/code&gt; is a plain-text tracker with three commands: &lt;code&gt;pending&lt;/code&gt; shows what has not been posted, &lt;code&gt;done&lt;/code&gt; marks an item, &lt;code&gt;status&lt;/code&gt; summarizes. A re-run reads the ledger first and skips anything already marked. It uses the standard library only, no install, and it has prevented every double-post since I built it.&lt;/p&gt;

&lt;p&gt;The lesson generalizes: any step in your pipeline that is not idempotent will eventually bite you. Make the tracking part of the pipeline, not part of your memory.&lt;/p&gt;

&lt;h2&gt;
  
  
  Rendering on 1 core and 2 GB is a discipline, not a limitation
&lt;/h2&gt;

&lt;p&gt;The whole pipeline runs on a 1 vCPU, 2 GB RAM box with no GPU. That constraint shaped the loop more than anything else:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;One process at a time.&lt;/strong&gt; I never fan out image jobs in parallel. On one core, parallelism just adds memory pressure and context switching for zero throughput gain.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;gc.collect()&lt;/code&gt; after every asset.&lt;/strong&gt; Python holds references longer than you think. Without this, a 300-asset batch would creep toward the 2 GB ceiling.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Measure peak RSS, not average.&lt;/strong&gt; &lt;code&gt;resource.getrusage(RUSAGE_SELF).ru_maxrss&lt;/code&gt; tells you the real ceiling you are living under. Average memory lies; peak memory gets your process OOM-killed.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;One file per subprocess for heavy renders.&lt;/strong&gt; When the render step exits, the OS reclaims the memory. A long-lived process accumulates.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Render at the resolution you need.&lt;/strong&gt; Inspecting a preview at 60 dpi instead of 300 is the single biggest time and memory saving in the loop.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;AI image generation costs money per image, so every wasted render is a direct loss. The 60 dpi preview habit alone cut my iteration cost noticeably, because I stopped rendering full resolution just to check composition.&lt;/p&gt;

&lt;h2&gt;
  
  
  Packaging is where a file becomes a product
&lt;/h2&gt;

&lt;p&gt;A zip with a real cover sells. A bare file does not. &lt;code&gt;pack_product.py&lt;/code&gt; takes a folder and produces three things: the product zip, a 1280×720 cover, and a 600×600 thumbnail. Covers are rendered with Pillow, one of only two third-party packages in the whole kit. The other is reportlab, for &lt;code&gt;md2pdf.py&lt;/code&gt;, which turns a Markdown file into a styled PDF with headings, tables, lists, and links in one command. The metadata and tracking scripts need nothing but Python.&lt;/p&gt;

&lt;p&gt;The folder layout matters as much as the scripts:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;project/
  assets/     raw output
  output/     ready-to-submit files + metadata.csv
  packs/      finished zips + covers
  research/   notes and sources
  scripts/    the tools
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Keep &lt;code&gt;research/&lt;/code&gt; forever. I deleted mine once to "clean up" and lost the raw material for several future products. That mistake cost me more than any server bill.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the loop produced
&lt;/h2&gt;

&lt;p&gt;With this loop running: 39 packaged products, 856 MB of output, from roughly 19,000 lines of pipeline code and 300+ pages of research behind the rules the scripts enforce. The first paid sale through this system was for the Digital Asset Factory itself, $2.80. I am not going to pretend that number is anything but small. What it proves is that the pipeline end to end works: assets get made, metadata passes review checks, products get packaged, and something sells.&lt;/p&gt;

&lt;p&gt;The four scripts I described are the packaging layer of that system. They are standalone, no framework, no cloud account, Python 3.9+, and two &lt;code&gt;pip install&lt;/code&gt;s if you want the PDF and cover tools. I bundled them, with the folder conventions and the RAM notes above, into the Digital Asset Factory, the full system built from this work. If you are a maker drowning in the same metadata and packaging problems I was, it is the honest version of what worked for me, failures included.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://revanzart-shop.revanzart.workers.dev/go/digital-asset-factory?src=devto" rel="noopener noreferrer"&gt;Get digital asset factory&lt;/a&gt;&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Building digital products? &lt;a href="https://revanzart-shop.revanzart.workers.dev/guides" rel="noopener noreferrer"&gt;Free guides &amp;amp; kits for digital product sellers&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>sideprojects</category>
      <category>ai</category>
      <category>automation</category>
      <category>marketing</category>
    </item>
    <item>
      <title>The 1280 720 Lesson: Why a Zip Isn't Shippable Until It Has a Cover"**</title>
      <dc:creator>Muhamad Revanza Rafif Hamdani</dc:creator>
      <pubDate>Sat, 07 Nov 2026 12:42:26 +0000</pubDate>
      <link>https://dev.to/revanzart/the-1280-720-lesson-why-a-zip-isnt-shippable-until-it-has-a-cover-m6o</link>
      <guid>https://dev.to/revanzart/the-1280-720-lesson-why-a-zip-isnt-shippable-until-it-has-a-cover-m6o</guid>
      <description>&lt;p&gt;I put a zip on Gumroad and walked away. Nothing happened. No sales, no clicks worth reading. The files inside were fine. The cover was a screenshot I took in a hurry, 900 pixels wide, squashed. I opened the listing a week later and understood the problem in about two seconds.&lt;/p&gt;

&lt;p&gt;The cover is not decoration. For digital assets it is the product.&lt;/p&gt;

&lt;p&gt;That is the 1280Ã—720 lesson.&lt;/p&gt;

&lt;h2&gt;
  
  
  A zip without a cover is not shippable
&lt;/h2&gt;

&lt;p&gt;Buyers never open your zip first. They see the cover, then the thumbnail, then maybe the contents. If the cover looks like a raw file dump, they scroll past. The zip could hold the best stickers on the platform and it does not matter, because nobody gets that far.&lt;/p&gt;

&lt;p&gt;I stopped treating covers as an afterthought. Every product now ships with two images, made by the same script that builds the zip:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Cover: 1280Ã—720. This is the size listed in the product image rules and it renders clean on a store page.&lt;/li&gt;
&lt;li&gt;Thumbnail: 600Ã—600. Square, for grids and search results.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Both come out of &lt;code&gt;pack_product.py&lt;/code&gt;. One command:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;python pack_product.py my-product &lt;span class="s2"&gt;"My Product"&lt;/span&gt; &lt;span class="s2"&gt;"A short subtitle"&lt;/span&gt; 9
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That writes &lt;code&gt;my-product.zip&lt;/code&gt;, &lt;code&gt;my-product-cover.jpg&lt;/code&gt;, and &lt;code&gt;my-product-thumb.jpg&lt;/code&gt; into a &lt;code&gt;_system&lt;/code&gt; folder next to where I run it. It uses Pillow, one dependency, nothing else. Point the output somewhere else with &lt;code&gt;--dir&lt;/code&gt; or the &lt;code&gt;PACK_ROOT&lt;/code&gt; environment variable, no need to edit the script.&lt;/p&gt;

&lt;p&gt;The lesson is boring and it cost me a product cycle: package the cover at the same moment you package the files. Never let them be two separate jobs.&lt;/p&gt;

&lt;h2&gt;
  
  
  The metadata headers are just as unforgiving
&lt;/h2&gt;

&lt;p&gt;Same story on the stock side. I had 1,700 assets made. I put 58 live on Adobe. First sale was $0.99. That number is small and real, and the reason so few went live was not the art. It was the CSV headers.&lt;/p&gt;

&lt;p&gt;Adobe wants a 5-column header. Vecteezy wants 4. Dreamstime wants 15. Get the header wrong by one column and the upload is rejected instantly. No partial import, no helpful error, just a no.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;make_metadata.py&lt;/code&gt; exists for that reason. It writes the exact header each channel demands and nothing else:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;python make_metadata.py ./my-assets &lt;span class="nt"&gt;--platform&lt;/span&gt; adobe    &lt;span class="nt"&gt;--out&lt;/span&gt; metadata.csv   &lt;span class="nt"&gt;--ext&lt;/span&gt; .jpg
python make_metadata.py ./my-assets &lt;span class="nt"&gt;--platform&lt;/span&gt; vecteezy &lt;span class="nt"&gt;--out&lt;/span&gt; vecteezy.csv   &lt;span class="nt"&gt;--ext&lt;/span&gt; .jpg
python make_metadata.py ./my-assets &lt;span class="nt"&gt;--platform&lt;/span&gt; dreamstime &lt;span class="nt"&gt;--out&lt;/span&gt; dreamstime.csv &lt;span class="nt"&gt;--ext&lt;/span&gt; .jpg
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Pass &lt;code&gt;--titles titles.json&lt;/code&gt; to supply real titles and keywords. Without it, the filename is title-cased and used as the title. That is fine for a first pass. Replace it before you submit, because a filename is not a title.&lt;/p&gt;

&lt;p&gt;The other thing that gets accounts flagged is running the batch twice. &lt;code&gt;upload_tracker.py&lt;/code&gt; is a ledger that remembers what you already posted:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;python upload_tracker.py pending items.txt
python upload_tracker.py &lt;span class="k"&gt;done&lt;/span&gt; &lt;span class="s2"&gt;"asset-001.jpg"&lt;/span&gt;
python upload_tracker.py status
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Double-posting is the fastest way to lose a channel. The tracker makes it impossible.&lt;/p&gt;

&lt;h2&gt;
  
  
  One core, two gigabytes, no GPU
&lt;/h2&gt;

&lt;p&gt;All of this ran on 1 vCPU and 2 GB RAM. No GPU. The habits that make that work are the same ones that keep a cover render from killing the box:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;One process at a time. Never fan out image jobs in parallel on a 1-core machine.&lt;/li&gt;
&lt;li&gt;Call &lt;code&gt;gc.collect()&lt;/code&gt; after each asset. Memory does not come back on its own.&lt;/li&gt;
&lt;li&gt;Measure peak RSS, not average. &lt;code&gt;resource.getrusage(RUSAGE_SELF).ru_maxrss&lt;/code&gt; shows the real ceiling.&lt;/li&gt;
&lt;li&gt;Render at the resolution you need. Inspecting a preview at 60 dpi beats rendering at 300.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The full pipeline packaged 39 products, 856 MB of output, behind roughly 19,000 lines of code and 300+ pages of research. None of that needed a bigger server. It needed fewer parallel jobs and honest memory numbers.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I would tell you to do first
&lt;/h2&gt;

&lt;p&gt;Do not start with 1,700 assets. Start with one product, one zip, one cover at 1280Ã—720, one thumbnail at 600Ã—600. Confirm the image shows up the way you want on the store page. Then do twenty. The pipeline only helps after the packaging is right, and the packaging is the part people skip.&lt;/p&gt;

&lt;p&gt;The four scripts that do this work are &lt;code&gt;make_metadata.py&lt;/code&gt;, &lt;code&gt;upload_tracker.py&lt;/code&gt;, &lt;code&gt;md2pdf.py&lt;/code&gt;, and &lt;code&gt;pack_product.py&lt;/code&gt;. Two of them use only the Python standard library. The other two need Pillow and reportlab. No framework, no cloud account.&lt;/p&gt;

&lt;p&gt;I built the full system from this work. It is the same pipeline, with the orchestrator, the tracking, and the packaging in one place.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://revanzart-shop.revanzart.workers.dev/go/digital-asset-factory?src=devto" rel="noopener noreferrer"&gt;Get digital asset factory&lt;/a&gt;&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Building digital products? &lt;a href="https://revanzart-shop.revanzart.workers.dev/guides" rel="noopener noreferrer"&gt;Free guides &amp;amp; kits for digital product sellers&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>sideprojects</category>
      <category>ai</category>
      <category>automation</category>
      <category>marketing</category>
    </item>
    <item>
      <title>Why pack_product.py Renders Both a 1280Ã 720 Cover and a 600Ã 600 Thumbnail</title>
      <dc:creator>Muhamad Revanza Rafif Hamdani</dc:creator>
      <pubDate>Fri, 06 Nov 2026 23:13:24 +0000</pubDate>
      <link>https://dev.to/revanzart/why-packproductpy-renders-both-a-1280a-720-cover-and-a-600a-600-thumbnail-314m</link>
      <guid>https://dev.to/revanzart/why-packproductpy-renders-both-a-1280a-720-cover-and-a-600a-600-thumbnail-314m</guid>
      <description>&lt;h1&gt;
  
  
  Why pack_product.py Renders Both a 1280Ã—720 Cover and a 600Ã—600 Thumbnail
&lt;/h1&gt;

&lt;p&gt;First product I packed was a set of 24 sticker PNGs. I rendered one image, 1280Ã—720, and used it everywhere. Uploaded to a grid listing. The grid cropped it to a square and my title text, sitting in the left third, disappeared. Nobody told me. I found out when a buyer asked what the product actually was.&lt;/p&gt;

&lt;p&gt;That day cost me a full evening. The fix was not a smarter crop. It was rendering two images from the start.&lt;/p&gt;

&lt;h2&gt;
  
  
  One image cannot serve two shapes
&lt;/h2&gt;

&lt;p&gt;A storefront uses two very different crops.&lt;/p&gt;

&lt;p&gt;The wide card on a product page is roughly 16:9. It has room for a title, a subtitle, and a visual. That is what a 1280Ã—720 render is for. It carries your words.&lt;/p&gt;

&lt;p&gt;The grid tile is square. Adobe's contributor listing, Vecteezy search results, Gumroad's product grid, all of them render items in a square cell. If you feed a 16:9 image into a square cell, something gets cut. Usually the thing you cared about.&lt;/p&gt;

&lt;p&gt;You can crop a 1280Ã—720 down to 600Ã—600. You lose 680 pixels of width. That is more than half the image. Text stretches or dies.&lt;/p&gt;

&lt;p&gt;So &lt;code&gt;pack_product.py&lt;/code&gt; renders both, from the same source layout, at the size each one needs. No crop, no stretch, no guessing.&lt;/p&gt;

&lt;h2&gt;
  
  
  What pack_product.py actually does
&lt;/h2&gt;

&lt;p&gt;One command:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;python pack_product.py my-product &lt;span class="s2"&gt;"My Product"&lt;/span&gt; &lt;span class="s2"&gt;"A short subtitle"&lt;/span&gt; 9
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It writes three files into &lt;code&gt;_system&lt;/code&gt; next to where you run it:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;my-product.zip&lt;/code&gt; with the deliverables&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;my-product-cover.jpg&lt;/code&gt; at 1280Ã—720&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;my-product-thumb.jpg&lt;/code&gt; at 600Ã—600&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Change the output location with &lt;code&gt;--dir&lt;/code&gt; or the &lt;code&gt;PACK_ROOT&lt;/code&gt; environment variable. No editing the script, no hardcoded paths in your repo.&lt;/p&gt;

&lt;p&gt;Pillow is the only third party dependency here. That is deliberate. One image library, one install, nothing else to break on a fresh box.&lt;/p&gt;

&lt;p&gt;The cover draws the seed, the title, and the subtitle into a wide layout with margins tuned for 16:9. The thumbnail redraws the same content with tighter margins and a bigger relative title, because at 600Ã—600 the viewer is looking at a small tile in a busy grid. Shrinking the cover would make the text unreadable at that size. Redrawing keeps it legible.&lt;/p&gt;

&lt;h2&gt;
  
  
  Memory on a 1 vCPU / 2 GB box
&lt;/h2&gt;

&lt;p&gt;The whole pipeline runs on one vCPU, 2 GB RAM, no GPU. That constraint shaped every decision in the packer.&lt;/p&gt;

&lt;p&gt;Three habits matter:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;One process at a time.&lt;/strong&gt; Never fan out image jobs in parallel on a single core. You get context switching and a memory spike, not speed.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Free between items.&lt;/strong&gt; Call &lt;code&gt;gc.collect()&lt;/code&gt; after each asset. Pillow holds image buffers longer than you think.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Measure peak, not average.&lt;/strong&gt; &lt;code&gt;resource.getrusage(RUSAGE_SELF).ru_maxrss&lt;/code&gt; tells you the real ceiling your process hit. Average RSS looks fine right up until the OOM killer arrives.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Render at the resolution you need.&lt;/strong&gt; The first version of my cover code drew a huge canvas and downscaled it. On 2 GB that was the difference between finishing a batch and losing it. Draw at 1280Ã—720 and 600Ã—600 directly. Inspecting a preview at 60 dpi beats rendering it at 300.&lt;/p&gt;

&lt;h2&gt;
  
  
  Numbers after 39 packs
&lt;/h2&gt;

&lt;p&gt;The pipeline has packaged 39 products, 856 MB of output. I have made about 1,700 assets. 58 are live on Adobe. My first sale was $0.99.&lt;/p&gt;

&lt;p&gt;Those numbers are small on purpose. I am telling you where the ceiling was, not where it wasn't. The two render sizes were not an optimisation. They were the fix for a real defect that showed up on product one.&lt;/p&gt;

&lt;p&gt;Package &lt;code&gt;make_metadata.py&lt;/code&gt; alongside it and the same folder produces your upload CSVs. The Adobe header is 5 columns, Vecteezy is 4, Dreamstime is 15. Get the header wrong and the channel rejects the file before a human ever sees it. &lt;code&gt;upload_tracker.py&lt;/code&gt; stops a re-run from double-posting, which is the fastest way to get an account flagged. &lt;code&gt;md2pdf.py&lt;/code&gt; turns a Markdown doc into something that looks like a product instead of a text file.&lt;/p&gt;

&lt;p&gt;All of it runs in the same folder layout:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;project/
  assets/     raw output
  output/     ready-to-submit files + CSV
  packs/      zips + covers
  research/   your notes
  scripts/    these tools
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Keep &lt;code&gt;research/&lt;/code&gt;. I deleted mine once to clean up. That was a mistake I have not repeated.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where this fits
&lt;/h2&gt;

&lt;p&gt;The two render sizes are a small thing. They are also the kind of small thing that decides whether your listing looks like a product or a file drop.&lt;/p&gt;

&lt;p&gt;I built the whole system around this work: metadata headers, the upload ledger, the PDF step, the packer, plus the orchestration and LLM routing layer on top. It runs on the same 1 vCPU / 2 GB box. It is not a tutorial that ends at "here is how you would do it". It is the working set.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://revanzart-shop.revanzart.workers.dev/go/digital-asset-factory?src=devto" rel="noopener noreferrer"&gt;Get digital asset factory&lt;/a&gt;&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Building digital products? &lt;a href="https://revanzart-shop.revanzart.workers.dev/guides" rel="noopener noreferrer"&gt;Free guides &amp;amp; kits for digital product sellers&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>sideprojects</category>
      <category>ai</category>
      <category>automation</category>
      <category>marketing</category>
    </item>
  </channel>
</rss>
