or how a rule about photos left me with more pictures than before
Aero Addicts is my side project for comparing triathlon, TT and aero road bikes. I'm an IT guy who does triathlon, and if you know any triathletes, you know what that means: hours of comparing gear so that a very average result gets a few seconds faster. So I built a site for it, where every number comes with a source.
Photos were harder, because I don't own any. The site only shows photos from the manufacturer's own website, CDN or press kit, with a credit and a link, hotlinked from the maker's server. They don't even go through the Next.js image optimiser, because that would keep a copy on my side.
That rule leaves gaps, so the site now draws the bikes itself. Or rather, an image model does: a side, a three-quarter and a front view in the style of a car design sketch, plus a faint pencil drawing for the page header.
Prerequisites: some Cloud Storage, Cloud Run and Terraform. It all runs on Google Cloud, but all you really need is object storage, an event when a file is written, and an image model.
Why not just use the photos
Brands publish different angles. Some have three clean views, many have one side shot and a gallery of people riding through the mountains.
Every brand photographs differently. Six cards from six brands look messy.
URLs die. After a website relaunch, the site falls back to a line drawing generated from the geometry. Correct, but nobody wants to look at it.
I can't process them. No resizing, no fixed aspect ratio, no Open Graph crops, because any of that means hosting a copy.
A drawing I generate is my own file. I can resize it, host it and give every bike the same three views on a 16:9 canvas. But it must never pass for a product photo: every sketch is captioned "Design sketch, generated from the manufacturer's photo", and a real photo, when there is one, stays the main image.
The setup
Cloud Storage: a private bucket for the photo archive and the full-size drawings, and a public one for the copies the site shows.
Eventarc, which calls the function when a file is written to the private bucket.
A Python Cloud Run function that does the drawing.
Gemini 3 Pro Image (Nano Banana Pro) on the Gemini Enterprise Agent Platform, the new name for Vertex AI since April, called through the
google-genaiSDK.
collect-images (monthly)
Claude finds photo URLs, opens a draft PR
│
▼
archive.py (weekly)
downloads, hashes, writes manifest.json
│ private bucket
▼
Cloud Run function (on every manifest write)
picks one photo, calls the model per view
│
├─▶ private: sketches/<slug>/<view>.png
└─▶ public: <slug>/<view>.webp
<slug>/header.png
│
▼
Next.js lists the bucket at build
Nothing calls the function directly. Writing bikes/<slug>/manifest.json is the event, so to redraw a bike, you write its manifest again.
Step 1: Collecting the photo URLs
Finding every official photo of a bike means a lot of clicking, so I gave that job to Claude. A skill file holds the rules, a GitHub Actions workflow runs it once a month, and the result is a draft pull request that I review. Each photo gets its source page, a credit, a short alt text and a kind, and only official ones (the maker's site, CDN or press kit) are ever shown on the site.
The key rule: Claude may never "invent a URL by editing a path, removing a resize parameter or guessing a filename". A URL that looks right and returns 404 is worse than no URL at all.
Step 2: Archiving the photos
A weekly Python script, archive.py, downloads each new URL politely (1.5 seconds between requests to the same host), checks with Pillow that it's an image with a longer side of at least 500 pixels, and stores it as bikes/<slug>/<sha256-prefix>.<ext> with source, credit and kind in the object metadata. Near-duplicates, whose perceptual hashes differ in at most 6 of 64 bits, are marked similar_to.
Everything ends up in bikes/<slug>/manifest.json, and the manifest is only rewritten when something changed for the bike. The bucket is private, since these are other companies' photos.
Step 3: The trigger
The function listens to google.cloud.storage.object.v1.finalized events on the private bucket. The photos and the function's own full-size drawings land there too, so the first thing it does is ignore everything that isn't a manifest:
def is_manifest(name: str) -> bool:
return name.startswith("bikes/") and name.endswith("/manifest.json")
@functions_framework.cloud_event
def on_object_finalized(event) -> None:
"""Eventarc entry point (google.cloud.storage.object.v1.finalized)."""
data = event.data
name, bucket = data["name"], data["bucket"]
if not is_manifest(name):
return # photos, style references and our own drawings land in this bucket too
process_manifest(bucket, name)
Skip that check and every file landing in the bucket, including each drawing it saves, starts the function again, which then tries to read a PNG as a manifest, fails and gets retried. Since manifests only change when the photos do, a new bike is drawn right after the weekly archive run, and an unchanged one triggers nothing.
Step 4: Calling the model
The function picks one photo per bike: it skips near-duplicates, prefers a photo whose alt says "drive side", then takes the largest file. For each view it sends that photo, a style reference and the prompt, and asks for a 16:9 image at 2K:
config = types.GenerateContentConfig(
response_modalities=["IMAGE"],
image_config=types.ImageConfig(aspect_ratio=ASPECT_RATIO, image_size=IMAGE_SIZE), # "16:9", "2K"
)
contents = [
types.Part.from_bytes(data=photo, mime_type=photo_mime), # "image 1" in the prompt
types.Part.from_bytes(data=ref, mime_type=ref_mime), # "image 2", the style reference
prompt,
]
resp = genai_client().models.generate_content(model=MODEL, contents=contents, config=config)
The style reference is a car design rendering kept in the repository: grey marker shading, thin construction lines, white paper. The prompt asks for "a finished automotive industrial-design rendering" of the existing product, with "no text, no numbers and no logos", and a {VIEW} placeholder gets one sentence per view.
The front view needed more words. The first ones came back with a side view drawn over them, probably because the model had a side photo in front of it. Its sentence now says "do not draw the side profile of the bike (as in image 1)", and the main prompt adds "never overlay, superimpose or combine two views of the bike in the same picture." Whatever is in the input photo, the model wants to draw it, so you have to tell it not to.


Every drawing is saved privately as sketches/<slug>/<view>.png, with the source photo's SHA-256 and the model in its metadata. Before drawing, the function checks them:
out = out_bucket.blob(f"{OUTPUT_PREFIX}/{slug}/{view}.png")
if not force and out.exists():
out.reload()
meta = out.metadata or {}
if meta.get("source_sha256") == photo_meta["sha256"] and meta.get("model") == MODEL:
... # up to date: at most re-publish the public copy, never redraw
continue
Same photo and same model means the drawing is skipped, so each one is paid for once, however often the function runs.
Step 5: The public copy
The site shows a copy from the public bucket aeroaddicts-sketches: <slug>/<view>.webp, at most 1,600 pixels wide, WebP quality 82, about 100 to 200 KB instead of a few megabytes. The public bucket only ever holds my drawings, never a manufacturer's photo.
The header drawing
The fourth drawing is a light pencil sketch from the three-quarter angle, shown faded behind the page headers. It has its own prompt ("a pure pencil line drawing", "no colour at all") and its own reference, a sheet of quick sports car sketches.
The Aero Addicts header is dark, so the drawing has to be just the lines, in one grey, on a transparent background. The model returns white paper, so the function converts it:
im = Image.open(io.BytesIO(png)).convert("L")
# Blur radius is tuned for ~1600 px: wider than a stroke, narrower than a shaded area.
around = im.filter(ImageFilter.GaussianBlur(im.width * 0.0025))
darker = ImageChops.subtract(around, im) # how much darker each pixel is than its neighbourhood
floor, gain = 6, 5
alpha = darker.point([min(255, max(0, round((v - floor) * gain))) for v in range(256)])
out = Image.merge("LA", (Image.new("L", im.size, grey), alpha)) # one grey, only the opacity varies
Each pixel is compared with a blurred copy of the image. If it's darker than its surroundings, it stays; otherwise it turns transparent. The blur is wider than a stroke and narrower than a shaded area, so the strokes and hatching survive and the paper disappears. A few lines of Pillow, no background-removal model.

The first backfill
The first run over the catalogue fired 126 bikes and produced 48 of the 378 drawings. Ten parallel instances hit the model's per-minute quota, and without a retry policy Eventarc dropped what it couldn't deliver. Now model calls retry with exponential backoff, a bike with a failed view throws so Eventarc delivers it again, and at most three instances run:
service_config {
max_instance_count = 3 # ten instances hit the image model's per-minute quota
max_instance_request_concurrency = 1 # one bike per instance
ingress_settings = "ALLOW_INTERNAL_ONLY"
}
event_trigger {
event_type = "google.cloud.storage.object.v1.finalized"
retry_policy = "RETRY_POLICY_RETRY" # busy instances and failed views are delivered again
}
A full run now takes hours instead of minutes, but it finishes.
Terraform and redraws
Everything is in Terraform, including the function's code: Terraform zips the folder and uploads it under its hash, so even a one-word prompt change deploys a new version. A prompt change goes through a pull request like any other code change. A manual "Redraw sketches" workflow rewrites the manifests of chosen bikes, either filling in what's missing or deleting the drawings first.
The website
The site never calls the function, and nothing about the drawings lives in git or Postgres. When a page is built, it lists the public bucket through the Cloud Storage JSON API and groups the files by bike, adding the object's generation as ?v= so a redrawn image isn't served from cache. Pages revalidate hourly, so a new drawing shows up without a deploy. The sketches appear on each bike page, replace the geometry drawing for bikes without a photo, and sit faded behind the headers.

Limitations (because nothing is free)
Two of the three views are guessed. The model sees one photo, usually the side, and fills in the rest from what it knows about bikes. Even the side view isn't exact: the S5 in the photo has a SRAM Red crankset, the sketch something closer to a generic Shimano one.
Nobody checks the drawings. The bike data goes through reviewed pull requests, but a drawing is published the moment it exists. The overlaid front views were spotted by looking at the pages, and at least one is still live:

Drawn on 8 October, a day before the prompt fix: a front view on top of a side view, and most of a Cervélo logo despite the "no logos" line. It was never redrawn, because a prompt change doesn't make a drawing outdated. Only a new photo or a new model does, so after changing a prompt you have to run
redrawyourself.The photo choice is too simple. "Prefer the drive side" is a text match, and "non-drive side" contains "drive side". It also ignores
kind, so a press photo would make the caption wrong. Both are small fixes and next on my list.Every image costs money. About 50 EUR so far. Google's Agent Platform price list puts a 1K or 2K Pro image at $0.134, plus the input tokens. Nano Banana 2 is $0.101 per 2K image, only about a quarter cheaper. Nano Banana 2.1 is $0.05, under half the price, if its quality is good enough for this.
The quota sets the speed. Three instances keep the function within it, which is why a full redraw takes hours.
Does it make sense for you? For a handful of products, no: draw them yourself or ask the brands for photos you may use. For a catalogue that keeps changing and photos you're not allowed to process, it's worth trying, as long as every drawing is clearly labelled as generated.
Wrapping up
I started with a rule that limited which images I could use and ended up with more than before: three views and a header drawing for every bike, all of them files I can resize and serve myself, and all of them labelled as generated.
And that's it! A private photo archive, one function that runs when a manifest is written, and a public bucket the site reads when it builds its pages.
The site: aeroaddicts.bike



