lp-product-ad-images
v5.0.0
Published
A loopengine ability: a generate_google_ad_images + check_google_ad_image_job tool pair — one job per whole batch (not per shot), bounded concurrency, incremental progress, per-shot product photo overrides, OpenAI or Google Nano Banana image-edit, every r
Maintainers
Readme
lp-product-ad-images
A loopengine ability: a tool for generating Google-Ads-ready product images from a real product photo, plus a skill that teaches an agent how to turn a request — a single image, a batch of one format, or a full 18-20 image asset set — into one shot list and one batch job, instead of only knowing how to plan one shape of batch.
What's in it
Tool —
generate_google_ad_images(product_image_url?, shots, quality?). Starts a whole batch as one job, not one job per shot:shotsis the full shot list for the request (one entry for a single-image request, dozens for a full set), each with its ownshot_type,scene_prompt,aspect_ratios, and optionally its ownproduct_image_url. Returns immediately with{ job_id, status: "processing" }instead of blocking until every image is ready — a batch of dozens of shots can take several minutes, and jobs run with bounded concurrency (AD_IMAGE_CONCURRENCY, default 4 at a time) rather than firing every generation at once. Pollcheck_google_ad_image_job({ job_id })for progress and results:{ status: "processing", progress, results }—resultsfills in incrementally as each (shot, ratio) unit finishes; check it even mid-run, don't wait for the job to fully settle to see what's ready.{ status: "done", progress, results }— every unit succeeded.{ status: "partial", progress, results }— a mix; eachresultsentry has its ownstatus/path/error, so successes and failures are both visible per-unit, not collapsed into one job-level verdict.{ status: "failed", progress, results }— every unit failed (most often a bad API key, or every shot sharing one deadproduct_image_url). If different shots reference different photos, a dead URL only fails the shots that use it — the rest of the batch settles normally as"partial".
Each
resultsentry is{ shot_index, shot_type, aspect_ratio, product_image_url, status, path?, width?, height?, error? }.pathis where the file actually landed — a local filesystem path (AD_IMAGE_OUTPUT_DIR, the default), or (AD_IMAGE_STORAGE=gcs) a time-limited signed HTTPS URL that's actually openable in a browser, falling back to a baregs://bucket/objectURI (not openable outside GCP's own tooling) if the configured credentials can't sign one — never inline base64 either way, so a full multi-format batch doesn't blow the conversation's own context budget. The background generation sends the real product photo to an image-edit model along with ashot_type-specific instruction to keep the product exactly as shown — not a text-only reinterpretation of it.shot_typeis one of:product_only— clean, product-alone shot.lifestyle_product— the product in realistic, plausible use.cover_lifestyle— an aspirational hero/cover shot; the product is present but the scene carries the mood.
product_image_urlcan be set once at the top level as the default every shot uses, and/or overridden per shot (shots[].product_image_url) when the request provides several photos of the product and different shots should be based on different ones — see the skill for how to pick which photo fits which shot. Each distinct URL is only ever fetched once per job even if many shots reference it, not once per shot.Two providers, chosen once via
AD_IMAGE_PROVIDER(a deployment setting, not a per-call argument):openai(default) —gpt-image-2.5-sunburstby default, configurable viaOPENAI_IMAGE_MODEL(e.g.gpt-image-2.5-flarefor faster, lower- precision generation). Sunburst is the default because this ability edits a real product photo into an ad rather than generating a scene from scratch, and editing precision is what keeps the product looking like the product. Generates at one of three fixed native pixel sizes (landscape 1536×1024, square 1024×1024, portrait 1024×1536).google— Google's Gemini image models, aka "Nano Banana":gemini-3.1-flash-image(Nano Banana 2, the default) orgemini-3.1-pro-image(Nano Banana Pro, higher quality and cost) viaGOOGLE_IMAGE_MODEL. Supports a realaspect_ratioparameter with ten presets — square and both portrait specs (4:5,9:16) are exact presets here, so those need no cropping at all; only landscape still gets a small trim from the nearest preset (16:9).
Each shot's
aspect_ratiosis an array, not a single value — but each ratio in it is its own independent, separately generated image-edit call, not cropped from a shared source, so a landscape and a square version of "the same shot" can still drift in composition, lighting, even framing, exactly as two separate shots would. Combining ratios into one shot is a bookkeeping convenience only — each ratio is its own billed generation regardless. Whichever provider is active, each ratio's generation targets whichever native size/preset is closest to it, then center-crops down to the exact ratio — never upscaled or padded. Supports all three Google Ads image formats:"1.91:1"(landscape, the default),"1:1"(square), and"4:5"/"9:16"(portrait).Storage, chosen once via
AD_IMAGE_STORAGE(defaultlocal):local— writes each PNG underAD_IMAGE_OUTPUT_DIR;path(anddownload_path) are a short/local-file?...URL — loopengine core's own generic file-serving route (requires loopengine >= 0.1.57), givinglocalstorage the same inline preview/download-button treatmentgcsgets below — as long asAD_IMAGE_OUTPUT_DIRresolves inside this deployment's own project directory (true for its own relative-path default; an absolute path elsewhere falls back to a bare filesystem path with no URL, same as before this route existed).gcs— uploads each PNG toAD_IMAGE_GCS_BUCKET(optionally underAD_IMAGE_GCS_PREFIX) instead;path(anddownload_path, for a forced browser download) are short/storage-redirect?provider=gcs&...URLs — loopengine core's own generic route (requires loopengine >= 0.1.55), which signs a fresh, short-lived V4 URL and redirects on every click, rather than this ability signing one long-lived URL itself at generation time. Only openable from a browser already authenticated to this same loopengine server (the same Basic Auth every other route there needs) — not a standalone link you can share outside it. Requiresnpm install @google-cloud/storagein your own project (lazily imported by both this tool's own upload step and loopengine core's redirect route, solocalusers never need it). Job-status files always stay local underAD_IMAGE_OUTPUT_DIR/.jobs/regardless of this setting. If Application Default Credentials alone can't sign (plaingcloud auth application-default logincan't; a service account key or IAMsignBlobvia impersonation can), setGOOGLE_APPLICATION_CREDENTIALS_JSONto the entire contents of a downloaded service-account key file — the one setup path that needs nothing but the GCP Console and the Admin UI's Environment tab, no shell/SSH access to wherever this is running required.
Tool —
check_google_ad_image_job(job_id). Reads back the status of a jobgenerate_google_ad_imagesstarted, from a JSON file underAD_IMAGE_OUTPUT_DIR/.jobs/— read-only, safe to poll as often as needed.Skill —
product-google-ad-images: how to size a request (one image, a batch of one format, or a full set) into oneshotsarray, how to read a job's incremental progress and handle apartialresult, how to pick which photo fits which shot when a request provides more than one, a suggested shot-type mix, how to write ascene_promptthat actually varies shot to shot, and why the tool needs the real product photo rather than a description of it.actauth rules —
generate-google-ad-images-allowedandcheck-google-ad-images-job-allowed, bothdecision: allow. Deliberately not gated behind a humanask— see the rule file's own comments for why (this is a cost-per-shot tool, not a destructive one, and the whole point is generating a full batch in one unattended run).
Install
npx loopengine add-ability lp-product-ad-images --agent <your-agent>Then:
- Pick a provider and set its key (via the Admin UI's Environment tab,
or directly in
.env):- OpenAI (default, no
AD_IMAGE_PROVIDERneeded):OPENAI_API_KEY, optionallyOPENAI_IMAGE_MODEL. - Google/Nano Banana:
AD_IMAGE_PROVIDER=google,GEMINI_API_KEY, optionallyGOOGLE_IMAGE_MODEL=gemini-3.1-pro-imagefor Nano Banana Pro instead of the default Nano Banana 2. - Optionally
AD_IMAGE_OUTPUT_DIRif you don't want generated images (when storage islocal) or job-status files (always) landing under./generated/ad-images. - Optionally
AD_IMAGE_STORAGE=gcsplusAD_IMAGE_GCS_BUCKET(and optionallyAD_IMAGE_GCS_PREFIX) to upload images to GCS instead of writing them locally — requires loopengine >= 0.1.55 (serves the/storage-redirectroute each generated image's URL now points at). Use a service account key (GOOGLE_APPLICATION_CREDENTIALSpointing at one, orGOOGLE_APPLICATION_CREDENTIALS_JSONpasted directly if you can't get a file onto the server) rather than plain user ADC — plain user ADC can't sign, and/storage-redirecthas no fallback for that the way this tool's own upload step does; a click just 502s. - Optionally
AD_IMAGE_CONCURRENCY(default4) to raise or lower how many generations one batch job runs at once — tune it against your actual provider rate limits.
- OpenAI (default, no
npm install sharpin your own project — this ability's tool uses it for the center-crop step, for both providers and both storage backends. If you setAD_IMAGE_STORAGE=gcs, alsonpm install @google-cloud/storage. Installing an ability copies its files in, it doesn't manage your project's ownpackage.json, so these are one-time manual steps (see loopengine's ownABILITIES.mdon why abilities are copied rather than imported).- If using
localstorage, add wherever generated images land (AD_IMAGE_OUTPUT_DIR, defaultgenerated/ad-images) to your project's own.gitignoreif you don't want to commit generated creative. Either way, that same directory's.jobs/subdirectory holds job-status files and is worth ignoring too.
Every shot costs real money against whichever provider/model you've configured — see the actauth rule's own comment if you want a per-batch approval instead of the default unattended behavior.
A job keeps running only as long as the agent process that started it
stays alive — fine under a long-lived server (npx loopengine
dev/serve), but a job started right before a short-lived, single-shot
CLI invocation exits may never get the chance to finish.
Example requests
The agent — not the end user — constructs this JSON: a user just says
something like "generate a full set from this photo" in plain language,
and the skill guides the agent through sizing that into a shots array
and writing each scene_prompt. These are what the agent ends up
calling generate_google_ad_images with, for a few different request
shapes:
"Generate one landscape image with product_only style from this
image: https://cdn.example.com/mug.png"
{
"product_image_url": "https://cdn.example.com/mug.png",
"shots": [
{ "shot_type": "product_only", "scene_prompt": "on a clean marble countertop, soft natural window light, straight-on angle", "aspect_ratios": ["1.91:1"] }
]
}"Generate a batch of 6 landscape images" (no style specified — the agent applies the shot-type mix itself)
{
"product_image_url": "https://cdn.example.com/mug.png",
"shots": [
{ "shot_type": "product_only", "scene_prompt": "flat lay, marble surface, overhead angle", "aspect_ratios": ["1.91:1"] },
{ "shot_type": "product_only", "scene_prompt": "three-quarter angle on dark wood, studio lighting", "aspect_ratios": ["1.91:1"] },
{ "shot_type": "lifestyle_product", "scene_prompt": "hand reaching for the mug on a kitchen counter, morning light", "aspect_ratios": ["1.91:1"] },
{ "shot_type": "lifestyle_product", "scene_prompt": "mug mid-use on a desk beside a laptop, soft afternoon light", "aspect_ratios": ["1.91:1"] },
{ "shot_type": "cover_lifestyle", "scene_prompt": "cozy reading nook, blanket, rain on the window, mug on the armrest", "aspect_ratios": ["1.91:1"] },
{ "shot_type": "cover_lifestyle", "scene_prompt": "outdoor patio at sunrise, steam rising from the mug", "aspect_ratios": ["1.91:1"] }
]
}"Generate 4 portrait images at 9:16 for Performance Max"
{
"product_image_url": "https://cdn.example.com/mug.png",
"shots": [
{ "shot_type": "product_only", "scene_prompt": "centered on a pedestal, vertical studio backdrop", "aspect_ratios": ["9:16"] },
{ "shot_type": "lifestyle_product", "scene_prompt": "held upright in hand, tiled kitchen wall behind", "aspect_ratios": ["9:16"] },
{ "shot_type": "cover_lifestyle", "scene_prompt": "tall bookshelf backdrop, mug on a side table, evening lamp light", "aspect_ratios": ["9:16"] },
{ "shot_type": "cover_lifestyle", "scene_prompt": "standing on a windowsill, city skyline blurred behind", "aspect_ratios": ["9:16"] }
]
}"Generate a full Google Ads image set from this photo" — still
one call and one job_id, shots combining every format:
{
"product_image_url": "https://cdn.example.com/mug.png",
"shots": [
{ "shot_type": "product_only", "scene_prompt": "flat lay, marble surface", "aspect_ratios": ["1.91:1", "1:1"] },
{ "shot_type": "product_only", "scene_prompt": "three-quarter angle, dark wood", "aspect_ratios": ["1.91:1", "1:1"] },
{ "shot_type": "lifestyle_product", "scene_prompt": "hand reaching for it on a counter", "aspect_ratios": ["1.91:1", "1:1"] },
{ "shot_type": "cover_lifestyle", "scene_prompt": "cozy reading nook, rain on the window", "aspect_ratios": ["1.91:1", "1:1"] },
{ "shot_type": "product_only", "scene_prompt": "centered on a pedestal, vertical backdrop", "aspect_ratios": ["9:16"] },
{ "shot_type": "lifestyle_product", "scene_prompt": "held upright, tiled kitchen wall", "aspect_ratios": ["9:16"] },
{ "shot_type": "cover_lifestyle", "scene_prompt": "windowsill, city skyline blurred behind", "aspect_ratios": ["9:16"] }
]
}(Shown abbreviated — a real full set repeats this pattern out to 18-20 landscape+square shots and 12-15 portrait shots, ~30-35 entries total.)
"Generate 3 draft square images, doesn't need to be high quality"
{
"product_image_url": "https://cdn.example.com/mug.png",
"shots": [
{ "shot_type": "product_only", "scene_prompt": "flat lay, plain white background", "aspect_ratios": ["1:1"] },
{ "shot_type": "product_only", "scene_prompt": "three-quarter angle, light gray background", "aspect_ratios": ["1:1"] },
{ "shot_type": "lifestyle_product", "scene_prompt": "on a desk beside a notebook", "aspect_ratios": ["1:1"] }
],
"quality": "low"
}quality applies to every shot in the batch — there's no per-shot
override.
"Generate a batch using these three photos — front, packaging, and in-hand — pick whichever fits each shot"
{
"product_image_url": "https://cdn.example.com/mug-front.png",
"shots": [
{ "shot_type": "product_only", "scene_prompt": "flat lay, marble surface", "aspect_ratios": ["1:1"], "product_image_url": "https://cdn.example.com/mug-front.png" },
{ "shot_type": "product_only", "scene_prompt": "boxed, on a shipping table", "aspect_ratios": ["1:1"], "product_image_url": "https://cdn.example.com/mug-packaging.png" },
{ "shot_type": "lifestyle_product", "scene_prompt": "held over a kitchen counter, morning light", "aspect_ratios": ["1:1"], "product_image_url": "https://cdn.example.com/mug-inhand.png" }
]
}The top-level product_image_url here acts as the fallback for any
shot that doesn't set its own — every shot above happens to override it,
but it wouldn't need to if one shot were fine using the default photo.
"Generate a batch for our Women's Running Shoes collection" — three different shoe models (not just colorways of one shoe), but still one closely related collection sharing a theme and landing page, so it's still one job (it maps to one Google Ads asset group), with the specific model rotated across shots rather than one dominating:
{
"shots": [
{ "shot_type": "product_only", "scene_prompt": "flat lay, studio white background", "aspect_ratios": ["1:1"], "product_image_url": "https://cdn.example.com/velocity-trainer.png" },
{ "shot_type": "product_only", "scene_prompt": "three-quarter angle, studio white background", "aspect_ratios": ["1:1"], "product_image_url": "https://cdn.example.com/trail-runner-pro.png" },
{ "shot_type": "product_only", "scene_prompt": "top-down, laces untied", "aspect_ratios": ["1:1"], "product_image_url": "https://cdn.example.com/cloud-cushion.png" },
{ "shot_type": "lifestyle_product", "scene_prompt": "worn on a morning trail run, dawn light", "aspect_ratios": ["1:1"], "product_image_url": "https://cdn.example.com/trail-runner-pro.png" },
{ "shot_type": "lifestyle_product", "scene_prompt": "laced up in a gym locker room", "aspect_ratios": ["1:1"], "product_image_url": "https://cdn.example.com/velocity-trainer.png" },
{ "shot_type": "cover_lifestyle", "scene_prompt": "runner mid-stride on a city street at sunrise", "aspect_ratios": ["1:1"], "product_image_url": "https://cdn.example.com/cloud-cushion.png" }
]
}Unrelated products (different category, different landing page) should
instead be separate generate_google_ad_images calls — one job, and
one report, per product. See the skill's "Multiple product photos"
section for the full reasoning.
Upgrading
npx loopengine upgrade-ability lp-product-ad-images --agent <your-agent>See loopengine's own ABILITIES.md for how abilities, installs, and
upgrades work in general.
