Creative Assets — one library for every plate and video

31 batches and 1,642 creatives went into one catalogued library. Then the live library quietly showed 7 of them, and the thumbs-up learning loop starved after 212 ratings. What a creative registry needs beyond "put everything in one place".
Chart: creative files registered per month versus ratings given, showing ratings stopped after September
Open source · MIT
Agentic Company
Agents crawl, summarize, draft assets, and prepare publish/sync. You approve anything public, paid, or irreversible.
View on GitHub →

Once agents can generate creatives, you stop having a shortage and start having an inventory problem. In three months our AI team registered 31 batches and 1,642 files — ad plates in four aspect ratios, video proposals, review reels, product stills — across 15 campaigns. This post is about the library that holds them, and about the two ways it quietly failed even though the design was right.

The short version: one library and a “not done until registered” rule worked. A derived index that could shrink did not. And a feedback loop that depends on someone clicking thumbs-up on hundreds of images starved after three sessions.

The setup

Every generator writes files wherever its project naturally writes them and then calls one script to register the batch: an id, a campaign, a surface (paid, organic, other), an owner, and the list of files with their kind, aspect, and for video the resolution and duration. Each batch gets a small meta.json record in a batches/ folder. A catalog.json index is rebuilt from those records, and the dashboard library reads the index: a timeline, campaign filters, a lightbox that plays videos from their poster frame, and thumbs up/down on every asset.

The rule we give the agents is strict: a creative is not done until it is registered, and a one-off preview HTML page does not count. Before that rule, every task produced its own throwaway demo page, nobody could find them a week later, and nobody could answer “did we already make a Story for this product?”

Some numbers from the 31 batches:

WhatCount
Batches / campaigns31 / 15
Files registered1,642
Batches by month23 in July, 5 in September, 3 in October
Batches that used a paid image model4 of 31 (under five dollars in total)
Largest single batch600 plates: 3 cities × 4 formats × 50 variants

That last line is worth noticing. Most of the volume was not “AI art”. It was templated rendering — a photo, a headline, a price label and a landing URL composed into every placement size — which costs nothing and produces hundreds of files in minutes. Volume is the reason you need a registry at all.

Failure one: the index forgot two thirds of the library

While writing this post I compared the live library against the batch records. The library listed 7 batches and 1,210 files. The records describe 31 batches and 1,642 files. Every July campaign was gone from the UI — the marketplace launch plates, the product video proposals, the stills — even though their records are in the repository.

The cause is a classic derived-data bug. The rebuild looked like this:

def rebuild_catalog():
    batches = []
    for meta_path in sorted(BATCHES_DIR.glob("*/meta.json"), reverse=True):
        meta = json.loads(meta_path.read_text())
        if meta.get("id"):
            batches.append(meta)
    CATALOG_PATH.write_text(json.dumps({"batches": batches}))

It overwrites the index with whatever batch folders exist in the checkout that runs it. We work with several git worktrees plus a runtime checkout, and generated assets are partly gitignored. The newest entries in the shrunken index are weekly review reels, so the most likely story is that the reel job registered its batch in a checkout that did not have the July folders, rebuilt the index from what it could see, and the library shrank. No error, no warning. The files were still there; they just stopped existing as far as anyone browsing was concerned.

The fix is to make the rebuild additive. Records on disk win; entries already in the index but missing on disk are kept and flagged; dropping them needs an explicit flag:

def rebuild_catalog(*, prune=False):
    batches = [meta for meta in read_records_on_disk()]
    if not prune:
        seen = {b["id"] for b in batches}
        for prev in load_catalog()["batches"]:
            if prev.get("id") and prev["id"] not in seen:
                batches.append({**prev, "record_missing": True})
    batches.sort(key=lambda b: (b.get("created_at") or "", b["id"]), reverse=True)
    write_catalog(batches)

A two-case test pins it: deleting a record keeps the entry with record_missing, and prune=True drops it. Restoring the July batches means running the rebuild once in a checkout that has every record, which is a deploy step, not a code change.

Failure two: the learning loop starved

The library has thumbs up and down on every asset, plus reason chips (image, copy, layout, format). A job aggregates the votes into a short learning file that generators are supposed to read before the next batch. Here is what the votes actually look like:

MeasureValue
Ratings in total212 (143 likes, 68 dislikes)
Rating sessions3: 162 on one day, 48 two days later, 2 two weeks after that
Share on a single campaign139 of 212
Ratings with a reason chip9, all “bad image”
Ratings with a written note2
Assets registered since mid-Septemberabout 1,200, rated: 0

So the learning file says, in effect, “these 15 files were liked, these 15 were disliked, reason: bad image”. That is a list of filenames, not a lesson. A generator cannot turn “c05/Story.jpg was bad” into a better next batch. And when I traced who reads the learning file, it was the weekly marketing report and the dashboard, not the rendering scripts. The loop was closed on paper and open in code.

The design flaw is asking a person to rate everything. Nobody rates 600 plates. People rate the first campaign they look at, enthusiastically, once. What would have worked is a forced, small sample with a required reason: show five assets per batch, require a reason chip on every dislike, and turn the reasons — not the filenames — into rules (“price bar covers the product”, “people do not look like our customers”). That is the change I am making next; the current numbers are the reason.

What did work: format checks in code

The part that held up is the boring one. Every feed slot has an exact size — 1080×1350 feed, 1080×1080 square, 1920×1080 landscape, 1080×1920 Story — and a source with the wrong aspect is re-rendered or cover-cropped, never padded. That rule exists because of one incident: a square photo dropped into a 4:5 frame went out with a 135-pixel white band top and bottom, 10% of the height. It looked fine as a thumbnail and amateurish in the feed.

The gate that came out of it scans each edge for rows of one flat colour and refuses the slide when the top and bottom bands are symmetric and each at least 8% of the height (left and right only when the bands are white, because some plates have designed navy side margins):

LETTERBOX_MIN_FRAC = 0.08   # each band >= 8% of the height
_BAND_TOLERANCE = 8         # max RGB drift inside a "flat" row
_BAND_SYMMETRY_PX = 8       # top and bottom within 8 px of each other

tb = min(b["top"], b["bottom"])
if tb >= int(h * LETTERBOX_MIN_FRAC) and abs(b["top"] - b["bottom"]) <= _BAND_SYMMETRY_PX:
    return f"letterboxed: {b['top']}px top / {b['bottom']}px bottom"

I ran it over every image in the current library: all 912 plates of the multi-city campaign pass, across four re-renders in three days. A check that runs on every file beats a rating that runs on a few.

Key takeaways

  1. Register every creative as a record, and make registration the last line of every generator. One script, one record per batch with campaign, surface, owner, aspect and video duration. The library replaces ad-hoc preview pages.
  2. Treat the index as derived data that must never shrink silently. Rebuild additively, flag entries whose source is missing, require an explicit prune, and pin it with a test. Multiple checkouts plus gitignored assets will otherwise eat your history.
  3. Compare the UI against the source of truth now and then. “Library shows 7, records say 31” took one query to find, and nobody had noticed.
  4. Do not build learning on exhaustive human rating. Sample a handful per batch, require a reason on every dislike, and aggregate reasons into rules a generator can follow — and check that the generator really reads them.
  5. Put format rules in code at the gate. Exact pixel sizes per placement and an automated letterbox check catch the defects humans miss on thumbnails.

If you want to copy it this week

  • Write a 20-line register script that drops a JSON record per batch next to the files, and call it from your two busiest generators.
  • Build the index from the records with a merge, not an overwrite, and add the two-case test above.
  • Add a letterbox check to whatever uploads feed images; start with the 8% / flat-colour rule.
  • Pick five assets from your last batch, rate them with a written reason each, and see whether the reasons repeat. If they do, that is your first rule.

Closing

Generative tools make assets cheap; the work moves to keeping them findable and learning from them. One library and one registration rule got us the first half. The second half needed an index that cannot forget and feedback small enough that people actually give it. The pattern is part of the open Agentic Company playbook.

— Alex

Open source · MIT
Agentic Company
Agents crawl, summarize, draft assets, and prepare publish/sync. You approve anything public, paid, or irreversible.
View on GitHub →
Facebook
Twitter
LinkedIn
Pinterest

Posts