lp-product-ad-images 3.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Evan Yan
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,242 @@
1
+ # lp-product-ad-images
2
+
3
+ A [loopengine](https://github.com/loopengine-co/loopengine) ability: a
4
+ tool for generating Google-Ads-ready product images from a real product
5
+ photo, plus a skill that teaches an agent how to turn a request — a
6
+ single image, a batch of one format, or a full 18-20 image asset set —
7
+ into one shot list and one batch job, instead of only knowing how to
8
+ plan one shape of batch.
9
+
10
+ ## What's in it
11
+
12
+ - **Tool** — `generate_google_ad_images(product_image_url, shots, quality?)`.
13
+ Starts a **whole batch as one job**, not one job per shot: `shots` is
14
+ the full shot list for the request (one entry for a single-image
15
+ request, dozens for a full set), each with its own `shot_type`,
16
+ `scene_prompt`, and `aspect_ratios`. Returns immediately with
17
+ `{ job_id, status: "processing" }` instead of blocking until every
18
+ image is ready — a batch of dozens of shots can take several minutes,
19
+ and jobs run with bounded concurrency (`AD_IMAGE_CONCURRENCY`, default
20
+ 4 at a time) rather than firing every generation at once. Poll
21
+ `check_google_ad_image_job({ job_id })` for progress and results:
22
+ - `{ status: "processing", progress, results }` — `results` fills in
23
+ incrementally as each (shot, ratio) unit finishes; check it even
24
+ mid-run, don't wait for the job to fully settle to see what's ready.
25
+ - `{ status: "done", progress, results }` — every unit succeeded.
26
+ - `{ status: "partial", progress, results }` — a mix; each `results`
27
+ entry has its own `status`/`path`/`error`, so successes and failures
28
+ are both visible per-unit, not collapsed into one job-level verdict.
29
+ - `{ status: "failed", progress, results }` — every unit failed (most
30
+ often a dead `product_image_url` or bad API key, since that applies
31
+ identically to every shot).
32
+
33
+ Each `results` entry is
34
+ `{ shot_index, shot_type, aspect_ratio, status, path?, width?, height?, error? }`.
35
+ `path` is where the file actually landed — a local filesystem path
36
+ (`AD_IMAGE_OUTPUT_DIR`, the default) or a `gs://bucket/object` URI
37
+ (`AD_IMAGE_STORAGE=gcs`) — never a URL or inline base64, so a full
38
+ multi-format batch doesn't blow the conversation's own context budget.
39
+ The background generation sends the real product photo to an
40
+ image-edit model along with a `shot_type`-specific instruction to keep
41
+ the product exactly as shown — not a text-only reinterpretation of it.
42
+ `shot_type` is one of:
43
+ - `product_only` — clean, product-alone shot.
44
+ - `lifestyle_product` — the product in realistic, plausible use.
45
+ - `cover_lifestyle` — an aspirational hero/cover shot; the product is
46
+ present but the scene carries the mood.
47
+
48
+ **Two providers**, chosen once via `AD_IMAGE_PROVIDER` (a deployment
49
+ setting, not a per-call argument):
50
+ - `openai` (default) — `gpt-image-1` by default, configurable via
51
+ `OPENAI_IMAGE_MODEL`. Generates at one of three fixed native pixel
52
+ sizes (landscape 1536×1024, square 1024×1024, portrait 1024×1536).
53
+ - `google` — Google's Gemini image models, aka "Nano Banana":
54
+ `gemini-3.1-flash-image` (**Nano Banana 2**, the default) or
55
+ `gemini-3-pro-image` (**Nano Banana Pro**, higher quality and cost)
56
+ via `GOOGLE_IMAGE_MODEL`. Supports a real `aspect_ratio` parameter
57
+ with ten presets — square and both portrait specs (`4:5`, `9:16`)
58
+ are exact presets here, so those need no cropping at all; only
59
+ landscape still gets a small trim from the nearest preset (`16:9`).
60
+
61
+ Each shot's `aspect_ratios` is an array, not a single value — but each
62
+ ratio in it is its own independent, separately generated image-edit
63
+ call, not cropped from a shared source, so a landscape and a square
64
+ version of "the same shot" can still drift in composition, lighting,
65
+ even framing, exactly as two separate shots would. Combining ratios
66
+ into one shot is a bookkeeping convenience only — each ratio is its
67
+ own billed generation regardless. Whichever provider is active, each
68
+ ratio's generation targets whichever native size/preset is closest to
69
+ it, then center-crops down to the exact ratio — never upscaled or
70
+ padded. Supports all three Google Ads image formats: `"1.91:1"`
71
+ (landscape, the default), `"1:1"` (square), and `"4:5"`/`"9:16"`
72
+ (portrait).
73
+
74
+ **Storage**, chosen once via `AD_IMAGE_STORAGE` (default `local`):
75
+ - `local` — writes each PNG under `AD_IMAGE_OUTPUT_DIR`; `path` in each
76
+ result is a real filesystem path.
77
+ - `gcs` — uploads each PNG to `AD_IMAGE_GCS_BUCKET` (optionally under
78
+ `AD_IMAGE_GCS_PREFIX`) instead; `path` is a `gs://bucket/object` URI.
79
+ Requires `npm install @google-cloud/storage` in your own project
80
+ (lazily imported, so `local` users never need it) and standard
81
+ Google Cloud auth (Application Default Credentials or
82
+ `GOOGLE_APPLICATION_CREDENTIALS`) — this ability doesn't take a
83
+ credentials env var of its own. Job-status files always stay local
84
+ under `AD_IMAGE_OUTPUT_DIR/.jobs/` regardless of this setting.
85
+ - **Tool** — `check_google_ad_image_job(job_id)`. Reads back the status
86
+ of a job `generate_google_ad_images` started, from a JSON file under
87
+ `AD_IMAGE_OUTPUT_DIR/.jobs/` — read-only, safe to poll as often as
88
+ needed.
89
+ - **Skill** — `product-google-ad-images`: how to size a request (one
90
+ image, a batch of one format, or a full set) into one `shots` array,
91
+ how to read a job's incremental progress and handle a `partial`
92
+ result, a suggested shot-type mix, how to write a `scene_prompt` that
93
+ actually varies shot to shot, and why the tool needs the real product
94
+ photo rather than a description of it.
95
+ - **actauth rules** — `generate-google-ad-images-allowed` and
96
+ `check-google-ad-images-job-allowed`, both `decision: allow`.
97
+ Deliberately not gated behind a human `ask` — see the rule file's own
98
+ comments for why (this is a cost-per-shot tool, not a destructive one,
99
+ and the whole point is generating a full batch in one unattended run).
100
+
101
+ ## Install
102
+
103
+ ```
104
+ npx loopengine add-ability lp-product-ad-images --agent <your-agent>
105
+ ```
106
+
107
+ Then:
108
+
109
+ 1. Pick a provider and set its key (via the Admin UI's Environment tab,
110
+ or directly in `.env`):
111
+ - OpenAI (default, no `AD_IMAGE_PROVIDER` needed): `OPENAI_API_KEY`,
112
+ optionally `OPENAI_IMAGE_MODEL`.
113
+ - Google/Nano Banana: `AD_IMAGE_PROVIDER=google`, `GEMINI_API_KEY`,
114
+ optionally `GOOGLE_IMAGE_MODEL=gemini-3-pro-image` for Nano Banana
115
+ Pro instead of the default Nano Banana 2.
116
+ - Optionally `AD_IMAGE_OUTPUT_DIR` if you don't want generated
117
+ images (when storage is `local`) or job-status files (always)
118
+ landing under `./generated/ad-images`.
119
+ - Optionally `AD_IMAGE_STORAGE=gcs` plus `AD_IMAGE_GCS_BUCKET` (and
120
+ optionally `AD_IMAGE_GCS_PREFIX`) to upload images to GCS instead
121
+ of writing them locally.
122
+ - Optionally `AD_IMAGE_CONCURRENCY` (default `4`) to raise or lower
123
+ how many generations one batch job runs at once — tune it against
124
+ your actual provider rate limits.
125
+ 2. `npm install sharp` in your own project — this ability's tool uses
126
+ it for the center-crop step, for both providers and both storage
127
+ backends. If you set `AD_IMAGE_STORAGE=gcs`, also
128
+ `npm install @google-cloud/storage`. Installing an ability copies
129
+ its files in, it doesn't manage your project's own `package.json`,
130
+ so these are one-time manual steps (see loopengine's own
131
+ `ABILITIES.md` on why abilities are copied rather than imported).
132
+ 3. If using `local` storage, add wherever generated images land
133
+ (`AD_IMAGE_OUTPUT_DIR`, default `generated/ad-images`) to your
134
+ project's own `.gitignore` if you don't want to commit generated
135
+ creative. Either way, that same directory's `.jobs/` subdirectory
136
+ holds job-status files and is worth ignoring too.
137
+
138
+ Every shot costs real money against whichever provider/model you've
139
+ configured — see the actauth rule's own comment if you want a per-batch
140
+ approval instead of the default unattended behavior.
141
+
142
+ A job keeps running only as long as the agent process that started it
143
+ stays alive — fine under a long-lived server (`npx loopengine
144
+ dev`/`serve`), but a job started right before a short-lived, single-shot
145
+ CLI invocation exits may never get the chance to finish.
146
+
147
+ ## Example requests
148
+
149
+ The agent — not the end user — constructs this JSON: a user just says
150
+ something like "generate a full set from this photo" in plain language,
151
+ and the skill guides the agent through sizing that into a `shots` array
152
+ and writing each `scene_prompt`. These are what the agent ends up
153
+ calling `generate_google_ad_images` with, for a few different request
154
+ shapes:
155
+
156
+ **"Generate one landscape image with product_only style from this
157
+ image: `https://cdn.example.com/mug.png`"**
158
+
159
+ ```json
160
+ {
161
+ "product_image_url": "https://cdn.example.com/mug.png",
162
+ "shots": [
163
+ { "shot_type": "product_only", "scene_prompt": "on a clean marble countertop, soft natural window light, straight-on angle", "aspect_ratios": ["1.91:1"] }
164
+ ]
165
+ }
166
+ ```
167
+
168
+ **"Generate a batch of 6 landscape images"** (no style specified —
169
+ the agent applies the shot-type mix itself)
170
+
171
+ ```json
172
+ {
173
+ "product_image_url": "https://cdn.example.com/mug.png",
174
+ "shots": [
175
+ { "shot_type": "product_only", "scene_prompt": "flat lay, marble surface, overhead angle", "aspect_ratios": ["1.91:1"] },
176
+ { "shot_type": "product_only", "scene_prompt": "three-quarter angle on dark wood, studio lighting", "aspect_ratios": ["1.91:1"] },
177
+ { "shot_type": "lifestyle_product", "scene_prompt": "hand reaching for the mug on a kitchen counter, morning light", "aspect_ratios": ["1.91:1"] },
178
+ { "shot_type": "lifestyle_product", "scene_prompt": "mug mid-use on a desk beside a laptop, soft afternoon light", "aspect_ratios": ["1.91:1"] },
179
+ { "shot_type": "cover_lifestyle", "scene_prompt": "cozy reading nook, blanket, rain on the window, mug on the armrest", "aspect_ratios": ["1.91:1"] },
180
+ { "shot_type": "cover_lifestyle", "scene_prompt": "outdoor patio at sunrise, steam rising from the mug", "aspect_ratios": ["1.91:1"] }
181
+ ]
182
+ }
183
+ ```
184
+
185
+ **"Generate 4 portrait images at 9:16 for Performance Max"**
186
+
187
+ ```json
188
+ {
189
+ "product_image_url": "https://cdn.example.com/mug.png",
190
+ "shots": [
191
+ { "shot_type": "product_only", "scene_prompt": "centered on a pedestal, vertical studio backdrop", "aspect_ratios": ["9:16"] },
192
+ { "shot_type": "lifestyle_product", "scene_prompt": "held upright in hand, tiled kitchen wall behind", "aspect_ratios": ["9:16"] },
193
+ { "shot_type": "cover_lifestyle", "scene_prompt": "tall bookshelf backdrop, mug on a side table, evening lamp light", "aspect_ratios": ["9:16"] },
194
+ { "shot_type": "cover_lifestyle", "scene_prompt": "standing on a windowsill, city skyline blurred behind", "aspect_ratios": ["9:16"] }
195
+ ]
196
+ }
197
+ ```
198
+
199
+ **"Generate a full Google Ads image set from this photo"** — still
200
+ **one** call and one `job_id`, `shots` combining every format:
201
+
202
+ ```json
203
+ {
204
+ "product_image_url": "https://cdn.example.com/mug.png",
205
+ "shots": [
206
+ { "shot_type": "product_only", "scene_prompt": "flat lay, marble surface", "aspect_ratios": ["1.91:1", "1:1"] },
207
+ { "shot_type": "product_only", "scene_prompt": "three-quarter angle, dark wood", "aspect_ratios": ["1.91:1", "1:1"] },
208
+ { "shot_type": "lifestyle_product", "scene_prompt": "hand reaching for it on a counter", "aspect_ratios": ["1.91:1", "1:1"] },
209
+ { "shot_type": "cover_lifestyle", "scene_prompt": "cozy reading nook, rain on the window", "aspect_ratios": ["1.91:1", "1:1"] },
210
+ { "shot_type": "product_only", "scene_prompt": "centered on a pedestal, vertical backdrop", "aspect_ratios": ["9:16"] },
211
+ { "shot_type": "lifestyle_product", "scene_prompt": "held upright, tiled kitchen wall", "aspect_ratios": ["9:16"] },
212
+ { "shot_type": "cover_lifestyle", "scene_prompt": "windowsill, city skyline blurred behind", "aspect_ratios": ["9:16"] }
213
+ ]
214
+ }
215
+ ```
216
+ (Shown abbreviated — a real full set repeats this pattern out to 18-20
217
+ landscape+square shots and 12-15 portrait shots, ~30-35 entries total.)
218
+
219
+ **"Generate 3 draft square images, doesn't need to be high quality"**
220
+
221
+ ```json
222
+ {
223
+ "product_image_url": "https://cdn.example.com/mug.png",
224
+ "shots": [
225
+ { "shot_type": "product_only", "scene_prompt": "flat lay, plain white background", "aspect_ratios": ["1:1"] },
226
+ { "shot_type": "product_only", "scene_prompt": "three-quarter angle, light gray background", "aspect_ratios": ["1:1"] },
227
+ { "shot_type": "lifestyle_product", "scene_prompt": "on a desk beside a notebook", "aspect_ratios": ["1:1"] }
228
+ ],
229
+ "quality": "low"
230
+ }
231
+ ```
232
+ `quality` applies to every shot in the batch — there's no per-shot
233
+ override.
234
+
235
+ ## Upgrading
236
+
237
+ ```
238
+ npx loopengine upgrade-ability lp-product-ad-images --agent <your-agent>
239
+ ```
240
+
241
+ See loopengine's own `ABILITIES.md` for how abilities, installs, and
242
+ upgrades work in general.
@@ -0,0 +1,25 @@
1
+ # decision: allow, not ask — deliberately, and worth being explicit about
2
+ # why. This tool has no destructive real-world side effect the way
3
+ # issue_refund/send_email do (see loopengine's own ABILITIES.md on
4
+ # cost-vs-danger gating); the actual risk is pure API spend, and one call
5
+ # can already cover a full 18-20+ shot batch (one job, many shots) —
6
+ # gating it behind human approval would mean approving the whole batch's
7
+ # cost up front in one shot anyway, not per-image, so "ask" here doesn't
8
+ # buy finer-grained control the way it might look like it would.
9
+ #
10
+ # If you want a cost ceiling instead, the two real options: change this
11
+ # rule's decision to "ask" (one approval per batch, not per image), or
12
+ # don't grant this tool at all and generate in smaller, human-supervised
13
+ # batches by calling it from outside a single unattended run.
14
+ - name: generate-google-ad-images-allowed
15
+ scope: "*/*"
16
+ tool: generate_google_ad_images
17
+ decision: allow
18
+
19
+ # Read-only status check on a job the rule above already allowed
20
+ # starting — gating this one too would mean approving twice for the same
21
+ # generation (once to start it, once to see if it's done).
22
+ - name: check-google-ad-images-job-allowed
23
+ scope: "*/*"
24
+ tool: check_google_ad_image_job
25
+ decision: allow
@@ -0,0 +1,18 @@
1
+ {
2
+ "loopengineVersion": "^0.1.24",
3
+ "tools": ["tools/generate_google_ad_images.ts", "tools/check_google_ad_image_job.ts"],
4
+ "skills": ["skills/product-google-ad-images"],
5
+ "actauth": "actauth/rules.yml",
6
+ "env": [
7
+ { "name": "AD_IMAGE_PROVIDER", "description": "Which image model actually generates: \"openai\" (default) or \"google\" (Nano Banana 2 / Nano Banana Pro). Only one provider's API key below is required, matching whichever you pick.", "secret": false },
8
+ { "name": "OPENAI_API_KEY", "description": "OpenAI API key — required when AD_IMAGE_PROVIDER=openai (the default).", "secret": true },
9
+ { "name": "OPENAI_IMAGE_MODEL", "description": "Which OpenAI image model to call. Defaults to gpt-image-1 if unset.", "secret": false },
10
+ { "name": "GEMINI_API_KEY", "description": "Google AI Studio / Gemini API key — required when AD_IMAGE_PROVIDER=google.", "secret": true },
11
+ { "name": "GOOGLE_IMAGE_MODEL", "description": "Which Gemini image model to call when AD_IMAGE_PROVIDER=google: gemini-3.1-flash-image (Nano Banana 2, the default) or gemini-3-pro-image (Nano Banana Pro, higher quality and cost).", "secret": false },
12
+ { "name": "AD_IMAGE_OUTPUT_DIR", "description": "Where job-status files always live, and where generated images are written when AD_IMAGE_STORAGE=local. Defaults to ./generated/ad-images if unset.", "secret": false },
13
+ { "name": "AD_IMAGE_STORAGE", "description": "Where generated images are saved: \"local\" (default, under AD_IMAGE_OUTPUT_DIR) or \"gcs\" (a Google Cloud Storage bucket). Job-status files stay local either way.", "secret": false },
14
+ { "name": "AD_IMAGE_GCS_BUCKET", "description": "GCS bucket name to upload generated images to — required when AD_IMAGE_STORAGE=gcs. Auth is via Application Default Credentials / GOOGLE_APPLICATION_CREDENTIALS, same as any other Google Cloud client.", "secret": false },
15
+ { "name": "AD_IMAGE_GCS_PREFIX", "description": "Optional object name prefix inside the GCS bucket (e.g. \"campaigns/summer/\"). Only used when AD_IMAGE_STORAGE=gcs.", "secret": false },
16
+ { "name": "AD_IMAGE_CONCURRENCY", "description": "Max number of image generations run at once within a single batch job. Defaults to 4 if unset — raise it for faster batches if your provider quota allows, lower it if you're hitting rate limits.", "secret": false }
17
+ ]
18
+ }
package/package.json ADDED
@@ -0,0 +1,21 @@
1
+ {
2
+ "name": "lp-product-ad-images",
3
+ "version": "3.0.0",
4
+ "description": "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, OpenAI or Google Nano Banana image-edit, real product photo in, every ratio independently generated, saved locally or to GCS — plus a skill for sizing and generating anything from one image to a full Google Ads asset set.",
5
+ "license": "MIT",
6
+ "keywords": ["loopengine", "loopengine-ability", "google-ads", "product-photography", "image-generation", "openai", "nano-banana", "gemini", "gcs"],
7
+ "files": [
8
+ "loopengine.ability.json",
9
+ "tools",
10
+ "skills",
11
+ "actauth",
12
+ "README.md",
13
+ "LICENSE"
14
+ ],
15
+ "dependencies": {
16
+ "sharp": "^0.35.0"
17
+ },
18
+ "optionalDependencies": {
19
+ "@google-cloud/storage": "^8.0.0"
20
+ }
21
+ }
@@ -0,0 +1,206 @@
1
+ ---
2
+ name: product-google-ad-images
3
+ description: How to turn a request — one image, a batch of one format, or a full asset set — into one generate_google_ad_images call with a full shot list, how to poll check_google_ad_image_job for progress and results, plus the shot-type mix, how to write a good scene_prompt, and why every shot needs the real product photo.
4
+ ---
5
+
6
+ # Product ad images
7
+
8
+ `generate_google_ad_images` takes the **whole shot list for the request** —
9
+ one or more shots, each its own `shot_type`/`scene_prompt`/`aspect_ratios` —
10
+ in a single call, and tracks the entire thing as **one job**, not one job per
11
+ shot. Every (shot, ratio) pair is still its own independent, real generation
12
+ under the hood — two shots, or two ratios of the same shot, can still come
13
+ out with different composition, lighting, even framing, exactly as separate
14
+ calls would (image generation isn't deterministic) — grouping them into one
15
+ call only changes how many `job_id`s you have to track, never how many
16
+ billed generations happen or whether they match each other.
17
+
18
+ The tool returns immediately with that one `job_id`, not the finished
19
+ images — see "Starting the job and polling it" below. Planning the shot
20
+ list itself (how many shots, what mix of `shot_type`s, what each
21
+ `scene_prompt` says) is still your job before you call the tool — a good
22
+ asset set is dozens of genuinely different compositions, not the same idea
23
+ repeated, and the tool has no opinion on what makes a good shot list, only
24
+ on how to execute one.
25
+
26
+ ## Sizing the request
27
+
28
+ Requests come in at three different scopes — read which one you got before
29
+ building the shot list, since each implies a different `shots` array:
30
+
31
+ - **"Generate one/a single `<format>` image..."** (optionally "with `<style>`
32
+ style") — one call, `shots` has exactly one entry. Map the format to
33
+ `aspect_ratios` (table below), take `shot_type` from the wording if it
34
+ named one (default `product_only` if not), and write the `scene_prompt`
35
+ yourself if none was given — don't stop to demand a creative brief for a
36
+ single image; a reasonable scene beats no image.
37
+ - **"Generate a batch (N) of `<format>` images..."** — one call, `shots` has
38
+ N entries, all at that one format's `aspect_ratios` only. A request
39
+ scoped to one format stays scoped to it — don't fold in the other formats
40
+ just because the full-set table below combines landscape+square
41
+ elsewhere. Apply the shot-type mix below across the N entries, and give
42
+ each its own distinct `scene_prompt`; N shots sharing one scene defeats
43
+ the point of a batch.
44
+ - **"Generate a full Google Ads image set..."** (or "all formats",
45
+ "everything") — still **one call**: `shots` has every entry from both
46
+ rows of the table below combined — 18-20 entries at
47
+ `["1.91:1", "1:1"]` plus 12-15 entries at `["9:16"]` (or `["4:5"]`) — all
48
+ in the same `shots` array, all under the one job this call starts.
49
+
50
+ Only ask the operator something before generating when it would actually
51
+ change what you'd otherwise do — the product's category/audience if neither
52
+ is obvious from the image or title and the shot mix would look wrong without
53
+ it, or which portrait ratio (`4:5` vs `9:16`) if the request needs portrait
54
+ and doesn't say which. Don't ask just to reconfirm a scope the request
55
+ already stated plainly; when scope genuinely isn't stated at all, default to
56
+ assuming the full set is wanted rather than guessing at a smaller one.
57
+
58
+ | Say | `aspect_ratios` |
59
+ | --- | --- |
60
+ | "landscape" | `["1.91:1"]` |
61
+ | "square" | `["1:1"]` |
62
+ | "landscape and square" (or the full-set landscape row) | `["1.91:1", "1:1"]` |
63
+ | "portrait" | `["9:16"]` or `["4:5"]` — ask only if both are plausible and the request doesn't say |
64
+
65
+ ## Starting the job and polling it
66
+
67
+ One call starts the whole thing:
68
+
69
+ ```json
70
+ {
71
+ "product_image_url": "https://...",
72
+ "shots": [
73
+ { "shot_type": "product_only", "scene_prompt": "...", "aspect_ratios": ["1.91:1", "1:1"] },
74
+ { "shot_type": "lifestyle_product", "scene_prompt": "...", "aspect_ratios": ["1.91:1", "1:1"] },
75
+ { "shot_type": "cover_lifestyle", "scene_prompt": "...", "aspect_ratios": ["9:16"] }
76
+ ]
77
+ }
78
+ ```
79
+
80
+ It returns immediately — `{ "job_id": "...", "status": "processing" }` —
81
+ before any image exists. The real work (fetching the product photo once,
82
+ then every shot's every ratio as its own generation, a few at a time in the
83
+ background) keeps running after the call returns; a batch of dozens of
84
+ shots can take several minutes. Poll
85
+ `check_google_ad_image_job({ "job_id": "..." })` to track it:
86
+
87
+ ```json
88
+ {
89
+ "status": "processing",
90
+ "progress": { "total": 36, "done": 22, "failed": 1, "processing": 13 },
91
+ "results": [
92
+ { "shot_index": 0, "shot_type": "product_only", "aspect_ratio": "1.91:1", "status": "done", "path": "...", "width": 1536, "height": 804 },
93
+ { "shot_index": 3, "shot_type": "lifestyle_product", "aspect_ratio": "1:1", "status": "failed", "error": "..." }
94
+ ]
95
+ }
96
+ ```
97
+
98
+ `results` fills in incrementally as each (shot, ratio) unit finishes — check
99
+ it even while `status` is still `"processing"` to see what's already ready,
100
+ rather than treating the whole job as opaque until the end. `status`
101
+ settles once `progress.processing` hits 0:
102
+
103
+ - `"done"` — every unit succeeded.
104
+ - `"partial"` — a mix; some units are `"done"` (with a real `path`), some
105
+ are `"failed"` (with their own `error`). Don't wait for `"done"` before
106
+ reporting a `"partial"` batch — the images that succeeded are real and
107
+ usable; call out the failed ones by their `error` rather than silently
108
+ dropping them.
109
+ - `"failed"` — every unit failed (most often a dead `product_image_url` or
110
+ a missing/invalid API key, since that failure applies identically to
111
+ every shot).
112
+
113
+ This background work only continues for as long as the underlying agent
114
+ process stays running — fine for a long-lived server (`npx loopengine
115
+ dev`/`serve`), but a job started right before a short-lived, single-shot
116
+ CLI invocation exits may never get the chance to finish.
117
+
118
+ ## Why image-edit, not text-to-image
119
+
120
+ Every shot is a real edit of the actual `product_image_url` you pass in,
121
+ not a text description of the product. Each shot-type prompt explicitly
122
+ tells the model to keep the product exactly as shown in that reference
123
+ image — same shape, colors, proportions, any printed text or logo. Do
124
+ not try to describe the product yourself in `scene_prompt`; the
125
+ reference image already establishes what it looks like, and repeating a
126
+ text description of it just invites the model to drift from the real
127
+ thing. `scene_prompt` is for the *scene* only: where it is, what's
128
+ around it, the lighting, the mood.
129
+
130
+ ## The three formats
131
+
132
+ Every format — landscape, square, portrait — is generated at its own
133
+ native size/preset regardless of how shots are grouped, since generating at
134
+ the ratio's own native canvas (rather than cropping it out of a different
135
+ one) keeps the most composition and resolution for that ratio. There's no
136
+ format-pairing rule: a shot with `aspect_ratios: ["1.91:1", "1:1"]`
137
+ produces two files from **two independent generations**, not one shared
138
+ photo cropped two ways — combining ratios into one shot just means one
139
+ `shot_index`'s worth of results to read together, not a cost or
140
+ consistency shortcut.
141
+
142
+ | Format | Shots | `aspect_ratios` per shot | Generations (billed) | Files produced |
143
+ | --- | --- | --- | --- | --- |
144
+ | Landscape + Square (grouped per shot) | 18-20 | `["1.91:1", "1:1"]` | 36-40 (2 per shot) | 36-40 |
145
+ | Portrait | 12-15 | `["9:16"]` or `["4:5"]` | 12-15 (1 per shot) | 12-15 |
146
+
147
+ Google Ads uses both `4:5` and `9:16` for different placements — see
148
+ "Sizing the request" above for when to ask which one versus just picking.
149
+
150
+ ## The shot-type mix
151
+
152
+ Apply this mix separately to the landscape+square shots and to the
153
+ portrait shots in the `shots` array — a reasonable starting split, adjust
154
+ based on what the product and campaign actually call for, this isn't a
155
+ fixed formula:
156
+
157
+ - **`product_only`** (~35% of that format's count): clean, no ambiguity
158
+ about what's for sale. Vary the surface, background color/texture,
159
+ and camera angle across these — flat lay, three-quarter angle,
160
+ straight-on — so they don't read as the same shot repeated.
161
+ - **`lifestyle_product`** (~35%): the product in real, plausible use.
162
+ Vary the setting and who/what is interacting with it (a hand reaching
163
+ for it, it sitting mid-use on a counter, etc.) — this is where most
164
+ of the "does someone actually want this" persuasion in an ad image
165
+ set comes from.
166
+ - **`cover_lifestyle`** (~30%): the hero/cover shots — aspirational
167
+ scenes where the product is present but the mood of the scene is
168
+ doing the work, not a tight product close-up. These are what a
169
+ campaign's top-performing creative is usually built around.
170
+
171
+ Ask the operator for the product's category and target audience first
172
+ if neither is obvious from context — a kitchen gadget's lifestyle shots
173
+ look nothing like a skincare product's, and a generic prompt produces
174
+ generic (and less effective) ad creative either way.
175
+
176
+ ## Writing a good `scene_prompt`
177
+
178
+ Be concrete about the same things a real photo brief would specify:
179
+
180
+ - **Setting** — where, specifically (a sunlit kitchen counter, not just
181
+ "kitchen"; a gym locker room, not just "gym").
182
+ - **Styling** — what else is in frame and why (props that make sense
183
+ for the product's actual use case, not generic clutter).
184
+ - **Lighting/mood** — morning light, moody evening, bright and clean
185
+ studio — this does more to make 18-20 images look like a real,
186
+ varied set than almost anything else.
187
+ - **Composition**, only if it matters for this shot — off-center,
188
+ close crop, negative space for ad text overlay.
189
+
190
+ Keep each `scene_prompt` to one clear idea. A prompt trying to cover
191
+ three different moods at once tends to produce a muddled result, not
192
+ three ideas blended well.
193
+
194
+ ## After generating
195
+
196
+ Once the job's `status` is `"done"` or `"partial"` (see "Starting the job
197
+ and polling it" above for when to stop polling), each successful `results`
198
+ entry's `path` is where the file actually landed — a local filesystem
199
+ path under `AD_IMAGE_OUTPUT_DIR` by default, or a `gs://bucket/object`
200
+ URI if the deployment has `AD_IMAGE_STORAGE=gcs` set — never a URL or
201
+ inline image data either way. Report the full list of generated paths
202
+ back, grouped by format (`aspect_ratio`) and then `shot_type` within
203
+ each, so the operator can review the actual files rather than having to
204
+ reconstruct what got made from one long `results` array. Call out any
205
+ `failed` entries by their `error` rather than silently dropping them
206
+ from the report.
@@ -0,0 +1,40 @@
1
+ import { readFile } from 'node:fs/promises'
2
+ import { join } from 'node:path'
3
+ import type { ToolDefinition } from 'loopengine'
4
+
5
+ // Mirrors generate_google_ad_images.ts's own jobPath — the two tools
6
+ // can't share a module (add-ability copies each tool file standalone,
7
+ // flattened, with no shared-module support), so this on-disk path
8
+ // convention is the actual contract between them, not a shared function.
9
+ function jobPath(outputDir: string, jobId: string): string {
10
+ return join(outputDir, '.jobs', `${jobId}.json`)
11
+ }
12
+
13
+ export const checkGoogleAdImageJob: ToolDefinition = {
14
+ name: 'check_google_ad_image_job',
15
+ description:
16
+ 'Check the status of a job started by generate_google_ad_images — one job covers the whole batch (every shot, every ratio), not just one image. Returns {"status":"processing"|"done"|"partial"|"failed", "progress":{"total","done","failed","processing"}, "results":[...]}. results fills in incrementally as each shot/ratio finishes — check it even while status is still "processing" to see what\'s ready so far. "partial" means some units succeeded and some failed; check each result entry\'s own "status"/"error" to see which. Poll this rather than assuming the batch finished right away — a batch of many shots can take several minutes.',
17
+ input_schema: {
18
+ type: 'object',
19
+ properties: {
20
+ job_id: {
21
+ type: 'string',
22
+ description: 'The job_id returned by generate_google_ad_images.',
23
+ },
24
+ },
25
+ required: ['job_id'],
26
+ },
27
+ execute: async (input) => {
28
+ const jobId = String(input.job_id)
29
+ const outputDir = process.env.AD_IMAGE_OUTPUT_DIR || './generated/ad-images'
30
+ try {
31
+ return await readFile(jobPath(outputDir, jobId), 'utf8')
32
+ } catch {
33
+ throw new Error(
34
+ `check_google_ad_image_job: no job found for job_id "${jobId}" — check the id, and that AD_IMAGE_OUTPUT_DIR hasn't changed since the job was started.`,
35
+ )
36
+ }
37
+ },
38
+ // Read-only — never writes anything, safe to run alongside anything else.
39
+ safe: true,
40
+ }
@@ -0,0 +1,583 @@
1
+ import { randomUUID } from 'node:crypto'
2
+ import { mkdir, writeFile } from 'node:fs/promises'
3
+ import { join } from 'node:path'
4
+ import sharp from 'sharp'
5
+ import type { ToolDefinition } from 'loopengine'
6
+
7
+ type ShotType = 'product_only' | 'lifestyle_product' | 'cover_lifestyle'
8
+
9
+ interface ShotSpec {
10
+ shotType: ShotType
11
+ scenePrompt: string
12
+ aspectRatioSpecs: string[]
13
+ targetRatios: number[]
14
+ }
15
+
16
+ interface UnitResult {
17
+ shot_index: number
18
+ shot_type: ShotType
19
+ aspect_ratio: string
20
+ status: 'done' | 'failed'
21
+ // A local filesystem path (default, AD_IMAGE_STORAGE=local), or a
22
+ // gs://bucket/object URI when AD_IMAGE_STORAGE=gcs. Present only when
23
+ // status is "done".
24
+ path?: string
25
+ width?: number
26
+ height?: number
27
+ error?: string
28
+ }
29
+
30
+ interface JobRecord {
31
+ job_id: string
32
+ // "processing" until every unit (one shot × one of its aspect_ratios)
33
+ // has settled. "done" only if every unit succeeded, "failed" only if
34
+ // every unit failed, "partial" if it's a mix — a batch of 30+
35
+ // independent generations WILL sometimes have a handful fail without
36
+ // the rest being any less usable, so collapsing that into a binary
37
+ // done/failed would either hide real failures or throw away good
38
+ // images.
39
+ status: 'processing' | 'done' | 'partial' | 'failed'
40
+ created_at: string
41
+ finished_at?: string
42
+ progress: { total: number; done: number; failed: number; processing: number }
43
+ // Grows incrementally as each unit finishes — a caller polling mid-run
44
+ // already sees every unit that's settled so far, not just a bare
45
+ // "processing" flag with no visibility for however many minutes a big
46
+ // batch takes.
47
+ results: UnitResult[]
48
+ }
49
+
50
+ // Job files live alongside the images themselves, under the same
51
+ // AD_IMAGE_OUTPUT_DIR — check_google_ad_image_job re-derives this same
52
+ // path independently (it can't import this file; add-ability copies each
53
+ // tool file standalone, flattened, with no shared-module support), so
54
+ // the on-disk path convention here is the actual contract between the
55
+ // two tools, not a shared function.
56
+ function jobPath(outputDir: string, jobId: string): string {
57
+ return join(outputDir, '.jobs', `${jobId}.json`)
58
+ }
59
+
60
+ async function writeJob(outputDir: string, record: JobRecord): Promise<void> {
61
+ await mkdir(join(outputDir, '.jobs'), { recursive: true })
62
+ await writeFile(jobPath(outputDir, record.job_id), JSON.stringify(record, null, 2))
63
+ }
64
+
65
+ // Job status files always stay local regardless of AD_IMAGE_STORAGE —
66
+ // they're small operational bookkeeping, not the generated creative
67
+ // itself, so there's no reason to route them through GCS too.
68
+ function validateStorageConfig(): void {
69
+ const storage = process.env.AD_IMAGE_STORAGE || 'local'
70
+ if (storage !== 'local' && storage !== 'gcs') {
71
+ throw new Error(`generate_google_ad_images: AD_IMAGE_STORAGE must be "local" or "gcs" — got "${storage}"`)
72
+ }
73
+ if (storage === 'gcs' && !process.env.AD_IMAGE_GCS_BUCKET) {
74
+ throw new Error('generate_google_ad_images: AD_IMAGE_GCS_BUCKET is not set (required when AD_IMAGE_STORAGE=gcs)')
75
+ }
76
+ }
77
+
78
+ // Saves one generated image and returns where it landed — a local
79
+ // filesystem path by default, or a gs://bucket/object URI when
80
+ // AD_IMAGE_STORAGE=gcs. @google-cloud/storage is imported lazily, not at
81
+ // the top of the file, so installing it is only required for callers
82
+ // who actually turn GCS storage on — everyone else (the local default)
83
+ // never needs it, same reasoning as why sharp is a manual `npm install`
84
+ // rather than something add-ability manages.
85
+ async function saveImage(args: { buffer: Buffer; filename: string; outputDir: string }): Promise<string> {
86
+ const storage = process.env.AD_IMAGE_STORAGE || 'local'
87
+ if (storage === 'gcs') {
88
+ const bucketName = process.env.AD_IMAGE_GCS_BUCKET as string // validateStorageConfig already required this
89
+ const objectName = `${process.env.AD_IMAGE_GCS_PREFIX || ''}${args.filename}`
90
+ // Imported by a variable, not a string literal, so tsc treats this as
91
+ // `Promise<any>` instead of trying to resolve @google-cloud/storage's
92
+ // own types at compile time — installing it is only required at
93
+ // runtime for callers who actually set AD_IMAGE_STORAGE=gcs; everyone
94
+ // else (the local default) would otherwise fail `tsc` just for not
95
+ // having a package they never use.
96
+ const gcsModuleName = '@google-cloud/storage'
97
+ let gcs: any
98
+ try {
99
+ gcs = await import(gcsModuleName)
100
+ } catch {
101
+ throw new Error(
102
+ 'generate_google_ad_images: AD_IMAGE_STORAGE=gcs requires the @google-cloud/storage package — npm install @google-cloud/storage in your own project.',
103
+ )
104
+ }
105
+ const client = new gcs.Storage()
106
+ await client.bucket(bucketName).file(objectName).save(args.buffer, { contentType: 'image/png' })
107
+ return `gs://${bucketName}/${objectName}`
108
+ }
109
+ await mkdir(args.outputDir, { recursive: true })
110
+ const outputPath = join(args.outputDir, args.filename)
111
+ await writeFile(outputPath, args.buffer)
112
+ return outputPath
113
+ }
114
+
115
+ // Keeps the actual product accurate across every shot instead of letting
116
+ // the model reinterpret it from a text description each time — see
117
+ // SKILL.md's own "Why image-edit, not text-to-image" note for the
118
+ // reasoning. Each prefix constrains what the edit is allowed to change;
119
+ // scene_prompt (the caller's own input) supplies the rest — the specific
120
+ // setting, styling, and mood for this one shot.
121
+ const SHOT_TYPE_PREFIX: Record<ShotType, string> = {
122
+ product_only:
123
+ 'Photorealistic e-commerce product photography. Keep the product exactly as shown in the reference image — same shape, colors, proportions, and any printed text or logo, unchanged. No people, no hands, no added props beyond a simple surface and background. Clean studio lighting, sharp focus on the product, commercial ad quality.',
124
+ lifestyle_product:
125
+ 'Photorealistic lifestyle product photography for an ad. Keep the product exactly as shown in the reference image — same shape, colors, proportions, and any printed text or logo, unchanged. Show it in realistic natural use — a hand, partial body, or its real-world setting interacting with it plausibly. The product stays clearly recognizable and is not obscured.',
126
+ cover_lifestyle:
127
+ 'Photorealistic lifestyle hero/cover photography for an ad campaign. Keep the product exactly as shown in the reference image — same shape, colors, proportions, and any printed text or logo, unchanged. Aspirational, editorial setting and natural lighting; the product is present and identifiable but the scene itself carries the mood, not a tight product close-up.',
128
+ }
129
+
130
+ function parseAspectRatio(spec: string): number {
131
+ const match = spec.match(/^(\d+(?:\.\d+)?)\s*:\s*(\d+(?:\.\d+)?)$/)
132
+ if (!match) throw new Error(`generate_google_ad_images: aspect_ratio must look like "1.91:1" — got "${spec}"`)
133
+ const w = Number(match[1])
134
+ const h = Number(match[2])
135
+ if (w <= 0 || h <= 0) throw new Error(`generate_google_ad_images: aspect_ratio must be positive — got "${spec}"`)
136
+ return w / h
137
+ }
138
+
139
+ // Center-crop only, off whichever size a provider actually generated —
140
+ // this ability's whole point is that the product itself must stay
141
+ // exactly as generated, so cropping never resizes or distorts it, only
142
+ // trims from whichever axis the target ratio is narrower on (often
143
+ // nothing at all, when the target matches what was generated exactly,
144
+ // like the square case usually does).
145
+ function cropRectFor(
146
+ targetRatio: number,
147
+ source: { width: number; height: number },
148
+ ): { left: number; top: number; width: number; height: number } {
149
+ const { width: srcW, height: srcH } = source
150
+ const srcRatio = srcW / srcH
151
+
152
+ if (targetRatio >= srcRatio) {
153
+ // Target is wider (or equal) than the source — keep full width,
154
+ // crop height down.
155
+ const height = Math.round(srcW / targetRatio)
156
+ return { left: 0, top: Math.round((srcH - height) / 2), width: srcW, height: Math.min(height, srcH) }
157
+ }
158
+ // Target is narrower/taller than the source — keep full height, crop
159
+ // width down.
160
+ const width = Math.round(srcH * targetRatio)
161
+ return { left: Math.round((srcW - width) / 2), top: 0, width: Math.min(width, srcW), height: srcH }
162
+ }
163
+
164
+ function slugify(text: string): string {
165
+ return text
166
+ .toLowerCase()
167
+ .replace(/[^a-z0-9]+/g, '-')
168
+ .replace(/^-+|-+$/g, '')
169
+ .slice(0, 40)
170
+ }
171
+
172
+ function ratioLabel(spec: string): string {
173
+ return spec.replace(/[^0-9]+/g, '-').replace(/^-+|-+$/g, '')
174
+ }
175
+
176
+ interface Generation {
177
+ buffer: Buffer
178
+ width: number
179
+ height: number
180
+ }
181
+
182
+ interface GenerationArgs {
183
+ productBytes: Buffer
184
+ productContentType: string
185
+ prompt: string
186
+ targetRatio: number
187
+ quality: string
188
+ }
189
+
190
+ // gpt-image-* only ever generates at a small fixed set of native pixel
191
+ // sizes — there is no way to request an arbitrary ratio like Google
192
+ // Ads' 1.91:1 landscape or 9:16 portrait specs directly. Every ratio
193
+ // gets its own independent generation at whichever native size is
194
+ // *closest* to it — never a shared landscape generation cropped down to
195
+ // other ratios — since cropping a 9:16 frame out of a 1536x1024
196
+ // landscape source would throw away most of the composition, and
197
+ // generating at the native 1024x1536 portrait size instead keeps nearly
198
+ // all of it. The same reasoning applies to every ratio, not just
199
+ // portrait: two ratios generated independently are two independently
200
+ // composed photos, never guaranteed to match, but each is the fullest,
201
+ // least-cropped version of its own ratio.
202
+ const OPENAI_NATIVE_SIZES = [
203
+ { width: 1536, height: 1024 }, // landscape
204
+ { width: 1024, height: 1024 }, // square
205
+ { width: 1024, height: 1536 }, // portrait
206
+ ] as const
207
+
208
+ async function generateWithOpenAI({ productBytes, productContentType, prompt, targetRatio, quality }: GenerationArgs): Promise<Generation> {
209
+ const apiKey = process.env.OPENAI_API_KEY
210
+ if (!apiKey) throw new Error('generate_google_ad_images: OPENAI_API_KEY is not set (required when AD_IMAGE_PROVIDER=openai, the default)')
211
+ const model = process.env.OPENAI_IMAGE_MODEL || 'gpt-image-1'
212
+
213
+ const nativeSize = OPENAI_NATIVE_SIZES.reduce((best, size) => {
214
+ const bestDiff = Math.abs(best.width / best.height - targetRatio)
215
+ const diff = Math.abs(size.width / size.height - targetRatio)
216
+ return diff < bestDiff ? size : best
217
+ })
218
+
219
+ const form = new FormData()
220
+ form.set('model', model)
221
+ form.set('prompt', prompt)
222
+ form.set('size', `${nativeSize.width}x${nativeSize.height}`)
223
+ form.set('quality', quality)
224
+ form.set('image', new Blob([productBytes], { type: productContentType }), 'product')
225
+
226
+ const res = await fetch('https://api.openai.com/v1/images/edits', {
227
+ method: 'POST',
228
+ headers: { Authorization: `Bearer ${apiKey}` },
229
+ body: form,
230
+ })
231
+ if (!res.ok) {
232
+ const detail = await res.text().catch(() => '')
233
+ throw new Error(`generate_google_ad_images: OpenAI image edit failed (HTTP ${res.status}) ${detail.slice(0, 300)}`)
234
+ }
235
+ const body = (await res.json()) as { data?: { b64_json?: string }[] }
236
+ const b64 = body.data?.[0]?.b64_json
237
+ if (!b64) throw new Error('generate_google_ad_images: OpenAI response carried no image data')
238
+
239
+ return { buffer: Buffer.from(b64, 'base64'), width: nativeSize.width, height: nativeSize.height }
240
+ }
241
+
242
+ // Google's Gemini image models ("Nano Banana") — gemini-3.1-flash-image
243
+ // (Nano Banana 2) and gemini-3-pro-image (Nano Banana Pro) — support a
244
+ // real aspect_ratio parameter with a fixed set of presets, unlike
245
+ // OpenAI's three pixel sizes. Every ratio still gets its own independent
246
+ // generation at whichever preset is nearest to it, same as the OpenAI
247
+ // path — square and both portrait specs (4:5, 9:16) are exact presets
248
+ // here so those need no crop at all; only 1.91:1 isn't itself a preset,
249
+ // so 16:9 (the closest) still gets a small trim afterward.
250
+ const GOOGLE_ASPECT_PRESETS = ['1:1', '16:9', '9:16', '3:2', '2:3', '3:4', '4:3', '4:5', '5:4', '21:9']
251
+
252
+ function nearestGooglePreset(targetRatio: number): string {
253
+ return GOOGLE_ASPECT_PRESETS.reduce((best, preset) => {
254
+ const bestDiff = Math.abs(parseAspectRatio(best) - targetRatio)
255
+ const diff = Math.abs(parseAspectRatio(preset) - targetRatio)
256
+ return diff < bestDiff ? preset : best
257
+ })
258
+ }
259
+
260
+ // Walks the Interactions API's steps[].content[] shape for the first
261
+ // image content block, rather than assuming a fixed index — a response
262
+ // can interleave text/image blocks, and which position the image lands
263
+ // in isn't a contract worth hardcoding against.
264
+ function findImageData(body: unknown): string | undefined {
265
+ if (!body || typeof body !== 'object') return undefined
266
+ for (const value of Object.values(body as Record<string, unknown>)) {
267
+ if (Array.isArray(value)) {
268
+ for (const item of value) {
269
+ if (item && typeof item === 'object') {
270
+ const obj = item as Record<string, unknown>
271
+ if (obj.type === 'image' && typeof obj.data === 'string') return obj.data
272
+ const nested = findImageData(obj)
273
+ if (nested) return nested
274
+ }
275
+ }
276
+ } else if (value && typeof value === 'object') {
277
+ const nested = findImageData(value)
278
+ if (nested) return nested
279
+ }
280
+ }
281
+ return undefined
282
+ }
283
+
284
+ async function generateWithGoogle({ productBytes, productContentType, prompt, targetRatio, quality }: GenerationArgs): Promise<Generation> {
285
+ const apiKey = process.env.GEMINI_API_KEY
286
+ if (!apiKey) throw new Error('generate_google_ad_images: GEMINI_API_KEY is not set (required when AD_IMAGE_PROVIDER=google)')
287
+ const model = process.env.GOOGLE_IMAGE_MODEL || 'gemini-3.1-flash-image' // Nano Banana 2; set to gemini-3-pro-image for Nano Banana Pro
288
+
289
+ const aspectRatio = nearestGooglePreset(targetRatio)
290
+ const imageSize = quality === 'high' ? '2K' : '1K'
291
+
292
+ const res = await fetch('https://generativelanguage.googleapis.com/v1beta/interactions', {
293
+ method: 'POST',
294
+ headers: { 'x-goog-api-key': apiKey, 'Content-Type': 'application/json' },
295
+ body: JSON.stringify({
296
+ model,
297
+ input: [
298
+ { type: 'text', text: prompt },
299
+ { type: 'image', mime_type: productContentType, data: productBytes.toString('base64') },
300
+ ],
301
+ response_format: { type: 'image', mime_type: 'image/png', aspect_ratio: aspectRatio, image_size: imageSize },
302
+ }),
303
+ })
304
+ if (!res.ok) {
305
+ const detail = await res.text().catch(() => '')
306
+ throw new Error(`generate_google_ad_images: Google image edit failed (HTTP ${res.status}) ${detail.slice(0, 300)}`)
307
+ }
308
+ const body: unknown = await res.json()
309
+ const b64 = findImageData(body)
310
+ if (!b64) throw new Error('generate_google_ad_images: Google response carried no image data')
311
+
312
+ const buffer = Buffer.from(b64, 'base64')
313
+ // Read real dimensions off the actual bytes rather than assuming what
314
+ // a given aspect_ratio+image_size pair produces — the crop step below
315
+ // needs the truth, not a guess.
316
+ const meta = await sharp(buffer).metadata()
317
+ if (!meta.width || !meta.height) throw new Error('generate_google_ad_images: could not read generated image dimensions')
318
+
319
+ return { buffer, width: meta.width, height: meta.height }
320
+ }
321
+
322
+ interface WorkUnit {
323
+ shotIndex: number
324
+ shotType: ShotType
325
+ scenePrompt: string
326
+ aspectRatioSpec: string
327
+ targetRatio: number
328
+ }
329
+
330
+ // Runs `items` through `worker`, at most `limit` concurrently — a plain
331
+ // `Promise.all` over every unit in a 30+ shot batch would fire that many
332
+ // simultaneous provider calls at once and likely trip rate limits;
333
+ // this instead keeps `limit` workers alive, each pulling the next
334
+ // not-yet-started item off the shared list until it's exhausted.
335
+ async function runWithConcurrency<T>(items: T[], limit: number, worker: (item: T) => Promise<void>): Promise<void> {
336
+ let next = 0
337
+ async function runOne(): Promise<void> {
338
+ while (next < items.length) {
339
+ const item = items[next++]
340
+ await worker(item)
341
+ }
342
+ }
343
+ await Promise.all(Array.from({ length: Math.min(limit, items.length) }, runOne))
344
+ }
345
+
346
+ // The actual work — fetching the product photo once, then every shot's
347
+ // every requested ratio as its own independent generation — all happens
348
+ // here, after generate_google_ad_images has already returned a job_id to
349
+ // the caller. Progress is persisted after each unit settles (not just
350
+ // once at the very end), so a caller polling mid-run sees real partial
351
+ // results instead of a bare "processing" flag for however long the
352
+ // whole batch takes.
353
+ async function runJob(args: {
354
+ jobId: string
355
+ outputDir: string
356
+ provider: string
357
+ productImageUrl: string
358
+ shots: ShotSpec[]
359
+ quality: string
360
+ concurrency: number
361
+ createdAt: string
362
+ }): Promise<void> {
363
+ const { jobId, outputDir, provider, productImageUrl, shots, quality, concurrency, createdAt } = args
364
+
365
+ const results: UnitResult[] = []
366
+ const progress = { total: 0, done: 0, failed: 0, processing: 0 }
367
+ for (const shot of shots) progress.total += shot.aspectRatioSpecs.length
368
+ progress.processing = progress.total
369
+
370
+ // Writes are chained (not fired independently) so two units finishing
371
+ // close together can't race and leave the file with an earlier,
372
+ // less-complete snapshot overwriting a later one — each chained write
373
+ // reads `results`/`progress` fresh at the moment it actually runs, by
374
+ // which point every synchronous push that happened before it in
375
+ // program order is already reflected.
376
+ let writeChain: Promise<void> = Promise.resolve()
377
+ function persist(finished: boolean): void {
378
+ writeChain = writeChain.then(() =>
379
+ writeJob(outputDir, {
380
+ job_id: jobId,
381
+ status: !finished
382
+ ? 'processing'
383
+ : progress.failed === 0
384
+ ? 'done'
385
+ : progress.done === 0
386
+ ? 'failed'
387
+ : 'partial',
388
+ created_at: createdAt,
389
+ finished_at: finished ? new Date().toISOString() : undefined,
390
+ progress: { ...progress },
391
+ results: [...results],
392
+ }),
393
+ )
394
+ }
395
+
396
+ let productBytes: Buffer
397
+ let productContentType: string
398
+ try {
399
+ const productRes = await fetch(productImageUrl)
400
+ if (!productRes.ok) throw new Error(`could not fetch product_image_url (HTTP ${productRes.status})`)
401
+ productBytes = Buffer.from(await productRes.arrayBuffer())
402
+ productContentType = productRes.headers.get('content-type') || 'image/png'
403
+ } catch (err) {
404
+ // The product photo is shared by every unit — if it can't be
405
+ // fetched at all, every unit fails identically rather than each
406
+ // independently re-attempting (and re-failing) the same fetch.
407
+ const message = err instanceof Error ? err.message : String(err)
408
+ shots.forEach((shot, shotIndex) => {
409
+ for (const aspectRatioSpec of shot.aspectRatioSpecs) {
410
+ results.push({ shot_index: shotIndex, shot_type: shot.shotType, aspect_ratio: aspectRatioSpec, status: 'failed', error: message })
411
+ progress.failed++
412
+ progress.processing--
413
+ }
414
+ })
415
+ persist(true)
416
+ await writeChain
417
+ return
418
+ }
419
+
420
+ const generate = provider === 'google' ? generateWithGoogle : generateWithOpenAI
421
+ const stamp = Date.now()
422
+
423
+ const units: WorkUnit[] = []
424
+ shots.forEach((shot, shotIndex) => {
425
+ shot.aspectRatioSpecs.forEach((aspectRatioSpec, i) => {
426
+ units.push({ shotIndex, shotType: shot.shotType, scenePrompt: shot.scenePrompt, aspectRatioSpec, targetRatio: shot.targetRatios[i] })
427
+ })
428
+ })
429
+
430
+ await runWithConcurrency(units, concurrency, async (unit) => {
431
+ try {
432
+ const prompt = `${SHOT_TYPE_PREFIX[unit.shotType]}\n\nScene: ${unit.scenePrompt}`
433
+ const generation = await generate({ productBytes, productContentType, prompt, targetRatio: unit.targetRatio, quality })
434
+ // Still a crop, not a resize — the provider's native size/preset
435
+ // for this ratio is rarely pixel-exact (e.g. 1.91:1 has no native
436
+ // OpenAI size and no Google preset), so this trims the small
437
+ // remainder rather than stretching or padding.
438
+ const crop = cropRectFor(unit.targetRatio, generation)
439
+ const cropped = await sharp(generation.buffer).extract(crop).png().toBuffer()
440
+
441
+ const sceneSlug = slugify(unit.scenePrompt) || 'shot'
442
+ const filename = `${stamp}-shot${unit.shotIndex}-${unit.shotType}-${sceneSlug}-${ratioLabel(unit.aspectRatioSpec)}.png`
443
+ const path = await saveImage({ buffer: cropped, filename, outputDir })
444
+
445
+ results.push({
446
+ shot_index: unit.shotIndex,
447
+ shot_type: unit.shotType,
448
+ aspect_ratio: unit.aspectRatioSpec,
449
+ status: 'done',
450
+ path,
451
+ width: crop.width,
452
+ height: crop.height,
453
+ })
454
+ progress.done++
455
+ } catch (err) {
456
+ results.push({
457
+ shot_index: unit.shotIndex,
458
+ shot_type: unit.shotType,
459
+ aspect_ratio: unit.aspectRatioSpec,
460
+ status: 'failed',
461
+ error: err instanceof Error ? err.message : String(err),
462
+ })
463
+ progress.failed++
464
+ } finally {
465
+ progress.processing--
466
+ persist(progress.processing === 0)
467
+ }
468
+ })
469
+
470
+ // Best-effort: if even the final write fails (e.g. the output dir was
471
+ // removed mid-run), there's nothing further to do —
472
+ // check_google_ad_image_job will just report the job as not found.
473
+ await writeChain.catch(() => {})
474
+ }
475
+
476
+ export const generateGoogleAdImages: ToolDefinition = {
477
+ name: 'generate_google_ad_images',
478
+ description:
479
+ 'Start a whole batch of Google Ads product photo shoots — one or more shots, each with its own shot_type/scene_prompt/aspect_ratios — as ONE job covering the entire request, not one job per shot. Every (shot, ratio) pair is its own independent, separately generated image-edit call, run with bounded concurrency in the background. Returns immediately with a job_id and status "processing"; poll check_google_ad_image_job with that job_id for progress and results — it fills in incrementally as each shot finishes, so a big batch is never a black box mid-run. For a single image, pass one shot with one ratio.',
480
+ input_schema: {
481
+ type: 'object',
482
+ properties: {
483
+ product_image_url: {
484
+ type: 'string',
485
+ description: 'Publicly reachable URL of the real product photo every shot in this batch is based on.',
486
+ },
487
+ shots: {
488
+ type: 'array',
489
+ minItems: 1,
490
+ items: {
491
+ type: 'object',
492
+ properties: {
493
+ shot_type: {
494
+ type: 'string',
495
+ enum: ['product_only', 'lifestyle_product', 'cover_lifestyle'],
496
+ description:
497
+ 'product_only: clean product-alone shot. lifestyle_product: product shown in realistic use. cover_lifestyle: aspirational hero/cover shot, product present but not the sole focus.',
498
+ },
499
+ scene_prompt: {
500
+ type: 'string',
501
+ description:
502
+ 'The specific setting, styling, mood, and composition for this one shot (e.g. "on a rustic wooden table with soft morning light, a coffee cup beside it"). Describe the scene only — do not restate what the product looks like, the reference photo already establishes that.',
503
+ },
504
+ aspect_ratios: {
505
+ type: 'array',
506
+ items: { type: 'string' },
507
+ description:
508
+ 'One or more target ratios as "W:H" for this shot, each generated independently — not cropped from a shared source. Default ["1.91:1"]. Google Ads\' three standard formats: "1.91:1" (landscape), "1:1" (square), "4:5" or "9:16" (portrait).',
509
+ },
510
+ },
511
+ required: ['shot_type', 'scene_prompt'],
512
+ },
513
+ description: 'The full shot list for this batch — one entry per shot. A single-image request is just one entry.',
514
+ },
515
+ quality: {
516
+ type: 'string',
517
+ enum: ['low', 'medium', 'high'],
518
+ description: 'Generation quality, also the main cost lever, applied to every shot in this batch — default "high" for ad-ready output.',
519
+ },
520
+ },
521
+ required: ['product_image_url', 'shots'],
522
+ },
523
+ execute: async (input) => {
524
+ const outputDir = process.env.AD_IMAGE_OUTPUT_DIR || './generated/ad-images'
525
+ // Which model actually does the generation — OpenAI's gpt-image-*
526
+ // (default) or Google's Nano Banana 2 / Nano Banana Pro. This is a
527
+ // deployment-wide choice, not a per-call one: set once via env, not
528
+ // exposed as a tool argument, since an operator picks a provider for
529
+ // cost/quality/quota reasons that don't vary shot to shot.
530
+ const provider = process.env.AD_IMAGE_PROVIDER || 'openai'
531
+ if (provider !== 'openai' && provider !== 'google') {
532
+ throw new Error(`generate_google_ad_images: AD_IMAGE_PROVIDER must be "openai" or "google" — got "${provider}"`)
533
+ }
534
+ validateStorageConfig()
535
+
536
+ const concurrencyRaw = Number(process.env.AD_IMAGE_CONCURRENCY || '4')
537
+ const concurrency = Number.isFinite(concurrencyRaw) && concurrencyRaw > 0 ? Math.floor(concurrencyRaw) : 4
538
+
539
+ const productImageUrl = String(input.product_image_url)
540
+ const shotsInput = Array.isArray(input.shots) ? input.shots : []
541
+ if (shotsInput.length === 0) throw new Error('generate_google_ad_images: shots must be a non-empty array')
542
+
543
+ const shots: ShotSpec[] = shotsInput.map((raw, i) => {
544
+ const shotObj = (raw ?? {}) as Record<string, unknown>
545
+ const shotType = String(shotObj.shot_type) as ShotType
546
+ if (!(shotType in SHOT_TYPE_PREFIX)) {
547
+ throw new Error(`generate_google_ad_images: shots[${i}].shot_type must be one of ${Object.keys(SHOT_TYPE_PREFIX).join(', ')} — got "${shotType}"`)
548
+ }
549
+ if (typeof shotObj.scene_prompt !== 'string' || !shotObj.scene_prompt) {
550
+ throw new Error(`generate_google_ad_images: shots[${i}].scene_prompt is required`)
551
+ }
552
+ const aspectRatioSpecs =
553
+ Array.isArray(shotObj.aspect_ratios) && shotObj.aspect_ratios.length > 0 ? shotObj.aspect_ratios.map(String) : ['1.91:1']
554
+ const targetRatios = aspectRatioSpecs.map(parseAspectRatio) // throws synchronously on a bad ratio spec, at any shot index
555
+ return { shotType, scenePrompt: shotObj.scene_prompt, aspectRatioSpecs, targetRatios }
556
+ })
557
+ const quality = typeof input.quality === 'string' && input.quality ? input.quality : 'high'
558
+
559
+ const jobId = randomUUID()
560
+ const createdAt = new Date().toISOString()
561
+ const total = shots.reduce((n, shot) => n + shot.aspectRatioSpecs.length, 0)
562
+ await writeJob(outputDir, {
563
+ job_id: jobId,
564
+ status: 'processing',
565
+ created_at: createdAt,
566
+ progress: { total, done: 0, failed: 0, processing: total },
567
+ results: [],
568
+ })
569
+
570
+ // Not awaited — see runJob's own comment for why. Errors inside it
571
+ // are caught per-unit and written into the job record itself, never
572
+ // thrown here, since by this point the caller has already moved on.
573
+ void runJob({ jobId, outputDir, provider, productImageUrl, shots, quality, concurrency, createdAt })
574
+
575
+ return JSON.stringify({ job_id: jobId, status: 'processing' })
576
+ },
577
+ // Each call is independent — writes its own uniquely-named job file
578
+ // and (once each unit's background work finishes) image file(s),
579
+ // reads nothing shared, no risk of two calls conflicting — so it's
580
+ // fine in ToolLane's parallel lane despite not being "read-only" in
581
+ // the usual sense that flag is for.
582
+ safe: true,
583
+ }