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 +21 -0
- package/README.md +242 -0
- package/actauth/rules.yml +25 -0
- package/loopengine.ability.json +18 -0
- package/package.json +21 -0
- package/skills/product-google-ad-images/SKILL.md +206 -0
- package/tools/check_google_ad_image_job.ts +40 -0
- package/tools/generate_google_ad_images.ts +583 -0
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
|
+
}
|