Inspiration

I work on Amazon advertising tooling, so I see the same thing constantly: a seller has a product, the ads are fine, the bids are fine, and the listing still underperforms. The leak is after the click. The imagery is the problem.

The obvious move is to point an image model at it. But that solves the easy half. Amazon rejects A+ Content for reasons that have nothing to do with whether the picture looks nice: the canvas is 970×600 and yours is 1024×1024, there's text in the bottom 20% where the mobile UI overlays it, there's a "50% OFF" badge, there's a competitor mark in frame. Rejection means resubmission, and resubmission is slow.

Then there's the part nobody builds. Six months later a client or a marketplace asks where an image came from, and "our AI made it" is not an answer. There's no record of which model produced it, what it cost, whether anything checked it, or how many attempts came before the one that shipped.

So the thing I actually wanted to build wasn't a generator. Generators are commoditised. I wanted the production system that sits around one: the rubric, the retry loop, the storage layout, the audit trail. The unglamorous half.

What it does

A+ Foundry generates Amazon A+ Content imagery and then does everything that happens after generation.

Generation runs against Amazon's real module specs, not arbitrary sizes. Header at 970×600, banner at 970×300, feature card at 300×300, comparison chart cell at 150×150, grid tile at 135×135. Each renders at 2× and downsamples on export.

Compliance is two layers. Deterministic checks handle what needs no judgement: dimensions, aspect ratio, colour mode, file size, sharpness via variance of the Laplacian. Then a vision model audits the things that do need judgement — pricing claims, superlatives, competitor brands, contact information, and text inside the mobile safe zone. Two vision calls run in parallel per asset, one on the full frame and one on a crop of the bottom strip.

The judge has to quote its evidence. A violation without a words_read field gets dropped. That rule exists because without it the judge rejected everything with observations like "the bottom strip shows the base of the bottle" — true, and not a violation.

Retry is a closed loop. A rejected asset triggers a new pipeline run linked to the one it replaces via parent_run_id, with the violation fed back as extra negative-prompt weight. Both versions stay on the record. The failure is part of the audit trail, not something swept away.

Fallback is horizontal and separate from retry. Five slots across four vendors: Together AI and fal.ai (both FLUX.1-schnell, reached through Hugging Face Inference Providers), then two Pollinations models, then a local renderer as the floor. A vendor outage moves to the next company, not the next attempt against the same one.

Listing readiness scores five merchandising properties measured off the pixels: subject prominence, subject separation from the backdrop, backdrop cleanliness, thumbnail legibility (measured as what a downscale/upscale round trip destroys), and safe-zone headroom. It's explicitly not a predicted click-through rate. I have no impression or sales data, so that number would be invented, and the first question anyone would ask is what it was trained on.

Per-ASIN reports roll runs up the way a seller thinks. Every other screen is organised by run, which is how the pipeline works and not how anyone reasons about their catalogue. The report shows the current state of each module, which modules have no asset at all (A+ Content is a set, so a gap is a finding), and which single measured property is weakest across the whole listing.

Storage writes two Backblaze B2 key layouts on every run. Manifests carry GOVERNANCE-mode Object Lock.

Verification is a public page behind no login. Drop an exported image and it reads the manifest embedded inside the file: provider, model, cost, canonical hash, and every attempt that preceded it.

How we built it

Backend is FastAPI on Python 3.11. Frontend is Next.js 15 with the App Router, React 19, and AG Grid for the data tables. Auth is Google Identity Services directly — public client ID only, no secret, no callback, no server session.

Genblaze does the generation and provenance work, and I leaned on it properly rather than wrapping it:

  • Pipeline / Step / PipelineResult for every generation. Retries use .from_result(parent) so lineage is a first-class property of the run rather than something I bolt on in SQL.
  • SyncProvider as the interface for three custom providers I wrote: Hugging Face Inference Providers, Pollinations, and Cloudflare Workers AI.
  • ObjectStorageSink with both KeyStrategy.HIERARCHICAL and KeyStrategy.CONTENT_ADDRESSABLE.
  • ObjectLockConfig for retention, ParquetSink for the run index behind the analytics page, canonical_hash for the tamper check.
  • SmartEmbedder / SidecarHandler to embed the manifest inside the exported PNG, with a sidecar JSON fallback for formats that can't carry one.
  • genblaze-s3's S3StorageBackend.for_backblaze() with preflight=True, so bad credentials fail at construction with a clear message instead of halfway through an upload after a paid generation.

Backblaze B2 holds one bucket with three key layouts, because there are three different questions:

runs/{asin}/{date}/{run_id}/     what happened for this product?
assets/{ab}/{cd}/{sha256}        have we produced these exact bytes before?
manifests/{run_id}.json          prove it

The run tree keeps every attempt including rejected ones, which is what an audit walks. The asset tree is content-addressed, so the key is the SHA-256 and identical output from two different ASINs collapses onto one object. In the shipped data, 49 stored run assets resolve to 36 unique files. Dedup is a property of the key rather than a nightly job.

Object Lock is GOVERNANCE mode, seven days, on manifests only. Bucket-wide COMPLIANCE retention would make every object undeletable by anyone including the account root for the whole window, development junk included. Locking just the manifest gets the same tamper-evidence for provenance while leaving the images manageable. The bucket is private, so the API serves assets through an authenticated proxy.

Challenges we ran into

Every credentialed provider died, one at a time. GMI Cloud returned 402 the whole event because sponsor credits never arrived. Cloudflare Workers AI burned its daily neuron allocation and never reset. Replicate returned 402, and a fresh API key didn't help because credit is per-account, not per-key. NVIDIA NIM looked like the rescue — I got a key, installed the adapter, and spent an hour on it. The only model in that key's catalogue was FLUX.1-schnell, and it returned zero bytes after 300 seconds at every supported dimension. I dropped it and left the adapter in the codebase.

What actually saved it was realising Hugging Face Inference Providers is a broker, not a host. One free token routes to Together, fal.ai, nscale and WaveSpeed — separate companies on separate hardware. That's what turned a chain of Pollinations models (one operator, one failure domain, however many models you list) into a genuinely multi-vendor one.

Diffusion models don't process negation. My first build_prompt listed the rules inline — "no discount badges, no percentage-off text" — and every single generation came back with discount badges and percentage-off text. The tokens are present, the "no" is ignored. Moving all prohibitions into the negative prompt fixed it immediately.

Then the prompt itself became the violation. I had written "Professional Amazon product photography." The model embossed the word amazon onto the bottle, and my own rubric correctly rejected it as a brand mark inside the safe zone. Any brand token in a prompt is a brand token the model may render. The prompt now names no marketplace at all.

A storage bug that reported success. My local storage backend used file:// as its URL base, which collided with the source URIs of generated assets. key_from_url matched, the sink concluded the object was already stored, and skipped the upload — while reporting success. Runs looked fine and nothing was in the bucket. Changing the base to an HTTP URL fixed it, and it's the bug I'd have shipped without noticing.

The dual storage layout was a lie for most of the build. content_addressable_sink() existed and was only ever called by a smoke test. The pipeline wrote the hierarchical layout alone, so assets/ held two objects from a test run while runs/ grew past a hundred, and the gallery's "Asset library" toggle was collapsing rows on asset_sha256 in SQL while describing itself as reading a B2 tree that real runs never populated. I caught it by counting objects by date and noticing one folder had stopped growing days earlier.

Mirroring turned out to need a server-side copy, not put_assets. By the time the mirror runs, the hierarchical sink has already rewritten each asset URL to its B2 object URL, and put_assets re-fetches from that URL — which 401s against a private bucket and would pull the bytes down and back up again even if it didn't.

The readiness score was wrong three times before it was right.

  1. I compared edge energy at full size against edge energy at thumbnail size. Every asset scored above 100%, because a smaller image concentrates the same edges into fewer pixels. Replaced with a downscale/upscale round trip measuring what the trip destroys.
  2. Safe-zone headroom used an absolute threshold and scored every well-composed image at zero. Obvious in hindsight: a product's base and its cast shadow live in the bottom strip by definition. It's now relative to the frame's own average detail.
  3. The subject mask was luminance-only, which put a coral sneaker on a beige backdrop at 7% of the frame. Switched to RGB distance.

Shipping a metric that reads 0/100 on good images is worse than shipping no metric.

The vision judge was too slow, then too strict. Running a large model meant 138-second jobs under concurrency. Switching to a faster vision model, parallelising the two calls, and capping input at 768px brought that to about 13 seconds. Then it rejected literally every image on safe-zone grounds until I required quoted evidence.

Two frontend bugs that looked like design problems. The Webflow template ships an animated film-grain overlay — fixed, full-viewport, mix-blend-mode: color-dodge, z-index 2147483647. Fine over a marketing hero, but it lifts black surfaces to grey and smears every table underneath it. And every template button duplicates its own label as a text-shadow 1.2em below, hidden by overflow: clip and slid up on hover as a marquee. That only works while the label is one line; on the verify page an input squeezed the button until "Look up" wrapped, the container grew, and the shadow copy became visible as ghosted doubled text.

A critical CVE on the last stretch. Vercel blocked the deploy on CVE-2025-55182, an RCE in React Server Components affecting React 19.0.0–19.2.0 and Next.js 15.x App Router. Patched to Next 15.5.22 and React 19.0.1.

Accomplishments that we're proud of

The retry loop actually closes, on real assets. Forcing a violation produces a genuinely non-compliant image, the judge rejects it with the text quoted off the image, and the system regenerates to a passing version — with both attempts on the record. Not a mock, not a fixture.

Cross-vendor failover is recorded, not diagrammed. There's a run in the shipped database where the primary provider 404'd and a different company served the retry, and both events are in the lineage with the real error text.

needs_review exists as a distinct status. "We found no violations" and "we could not check" must never collapse into the same verdict. A degraded report — judge unreachable — is never reported as a pass. That's the one thing this system could do that would be genuinely dishonest, and it can't.

Pass rate only counts attempts that reached the rubric. A provider that errored has no pass rate at all. "Breaks Amazon's rules" and "the endpoint was down" are different failures with different fixes, and averaging them together produces a number that means nothing.

No fabricated data anywhere. No mock images in the gallery, no placeholder runs in the analytics, no invented performance predictions. I purged 68 rows and their B2 objects partway through specifically to keep the reliability numbers honest. Every figure on screen came from a real run.

Total spend: $0.00, metered rather than assumed. The whole chain runs free-tier, and the cost column is populated from what each provider actually reported, so a paid provider entering the chain shows up immediately.

What we learned

Provider redundancy is about organisations, not models. I started with three Cloudflare models in the chain and thought that was resilience. When the account hit its daily cap, all three died in the same second. Listing four models from one operator is one point of failure with extra steps.

Prompt engineering for compliance is mostly about what you don't say. Every prohibition in a positive prompt is a suggestion. Every brand name is a rendering instruction.

Calibration is the whole job for anything scored. Writing five metrics took twenty minutes. Making them produce numbers that discriminate between a good image and a bad one took three rewrites and repeated checks against real renders.

"It reported success" is not "it worked." The file:// collision and the never-called dedup sink both passed every test I had, because my tests checked return values instead of the bucket.

Honest degradation beats false confidence. The most useful design decisions here were the ones that made the system admit uncertainty: needs_review, requiring quoted evidence, refusing to predict a click-through rate, excluding errored attempts from pass rate.

What's next for A+ Foundry

Pull the brief from the ASIN. Amazon's SP-API Catalog Items endpoint returns title, bullets and attributes for an ASIN, so typing the ASIN could draft the brief automatically. I didn't build it because SP-API needs a Seller Central account plus app registration and OAuth approval, which isn't obtainable in a hackathon window. It's the most obvious next feature and the seam is already in the right place.

Publish straight to the listing. Right now the export is a download. With SP-API the compliant asset could go back to the listing directly, closing the loop from brief to live A+ module.

Learn the rubric from real rejections. Every Amazon rejection is labelled training data. Feeding actual rejection reasons back would turn a hand-written rubric into one that reflects what Amazon enforces in practice, which is not always what the policy document says.

Readiness against outcomes. The scores are honest measurements today with no claim about performance. Joined against real CTR and conversion data, they could become a calibrated predictor — and at that point the number would be defensible, which it isn't now.

Batch generation across a catalogue. The per-ASIN report already knows which modules are missing. Generating every gap across a few hundred listings in one queued pass is the operational version of this product.

Signed manifests. The manifest carries a canonical hash and sits under Object Lock, which proves it hasn't been altered in place. Cryptographic signing would let a third party verify origin without trusting the bucket at all.

Built With

Share this project:

Updates

Submission history