Almost every product photo ends up needing the same three versions:
- a transparent cut-out for ads, marketplaces and anything you composite later,
- a packshot with white space around it for the catalogue,
- a web size that loads fast on the product page.
By hand that is a few minutes per photo, which is fine for five photos and not for five hundred. n8n is the obvious next step, and it is also where these workflows tend to sprawl: one HTTP Request node per service, binary data dragged from node to node, the same file uploaded once per branch.
This tutorial builds the whole thing as one small n8n workflow. A photo lands in a Google Drive folder, and a minute later a Google Sheet has three new rows, one per variant, each with a permanent URL.
Google Drive: new photo → Download Photo → Upload Once ─┬─ Cut-out remove_bg (AI) ├─ White Packshot pad 120 px of #ffffff (deterministic) └─ Web Size resize to ≤ 1200 px (deterministic) → Merge → One Row Per Variant → Google Sheets One idea keeps it small: upload once, then refer to the image by its id. All three branches point at the same asset, so nothing is uploaded twice. Each branch is its own job with its own output and URL, so a branch that fails costs only its own row.
The image steps run on ImageStep, an image API for automations. Disclosure: I build it. The workflow, plus a Node.js version of the same pipeline, is MIT-licensed in the recipes folder of the public SDK repo. Everything below comes from there.
What you need
- n8n, Cloud or self-hosted.
- Google Drive and Google Sheets credentials in n8n.
- An ImageStep API key from imagestep.dev/keys. The Free plan includes 200 deterministic ops a month, and new accounts get a one-time $3 welcome credit for AI ops. Step 4 shows the exact price of a run before anything is charged.
- A product photo (JPEG, PNG, WebP or HEIC). If you don't have one handy, use
recipes/examples/product.jpg, a 1024 × 1024 picture of a camping mug. It's the one in the pictures below.
Step 1: Install the ImageStep node and add your key
- n8n Cloud and recent editors: click + on the canvas, search ImageStep in the nodes panel, pick the one with the verified badge and click Install.
- Self-hosted: Settings → Community Nodes → Install, package
n8n-nodes-imagestep.
Then create an ImageStep API credential. Paste your key into API Key and leave Base URL at https://api.imagestep.dev. The credential test reads your key's usage, which only a valid key can do, so a green test means the key works.
Step 2: Import the workflow
Download the template:
curl -LO https://raw.githubusercontent.com/imagestep/imagestep-sdk/main/recipes/product-trio/n8n-template.json Import it with Import from File in a new workflow's ⋯ menu. You can also open the file, copy all of it, click an empty canvas and paste. Every credential in it is a REPLACE_ME and every folder or sheet id a REPLACE_WITH_…, so open these nodes once and pick your own:
| Node | What to set |
|---|---|
| New Product Photo (Google Drive Trigger) | your Drive credential and the folder to watch |
| Download Photo | the same Drive credential |
| Upload Once, Cut-out (remove_bg), White Packshot (pad), Web Size (resize) | your ImageStep credential |
| Append Manifest Row | your Sheets credential and the spreadsheet |
Give the sheet a header row with these columns: file, variant, assetId, publicUrl, width, height, jobId.
Step 3: What each node does
You can run the workflow as imported. This section is what you need when you adapt it.
New Product Photo → Download Photo. The trigger checks the folder every minute for new files. Download Photo puts each file's bytes on the binary property data.
Upload Once. Resource Asset, Operation Upload, Binary Property data, Wait Until Ready on, collection product-trio. It sends the bytes and waits until ImageStep has finished reading the file. Its output is the asset, and the branches only need its assetId.
The three branches. Each one is Resource Operation, Operation Run, with Input set to Asset IDs and the Asset IDs field set to {{ $json.assetId }}. Only the op and its JSON parameters differ:
| Node | Op | Parameters |
|---|---|---|
| Cut-out (remove_bg) | remove_bg | {} |
| White Packshot (pad) | pad | {"top": 120, "bottom": 120, "left": 120, "right": 120, "background": "#ffffff"} |
| Web Size (resize) | resize | {"width": 1200, "fit": "inside", "withoutEnlargement": true} |
All three have Store Result, Wait for Result and Publish Outputs turned on. Store Result is the switch that matters, and it is off by default. With it off, a deterministic op like pad hands the bytes straight back and stores nothing, and an AI op like remove_bg returns asset ids without publishing them. That is right for a one-off conversion and wrong here, because we want every variant kept as an asset with its own permanent URL. With it on, each branch runs as a job, waits for it (240 seconds for the cut-out, 120 for the other two) and publishes the output.
A branch's output looks like this (trimmed):
{ "jobId": "job_…", "status": "COMPLETED", "op": "pad", "outputs": [ { "assetId": "ast_…", "publicUrl": "https://cdn.imagestep.dev/…", "width": 1264, "height": 1264 } ] } Merge has three inputs, one per branch, and passes all three results on as one list.
One Row Per Variant is a Code node. It turns each job into a sheet row and names the variant after the op:
const file = $('New Product Photo').first().json.name; const VARIANT = { remove_bg: 'cutout', pad: 'packshot', resize: 'web' }; return $input.all().map((item) => { const out = item.json.outputs?.[0] || {}; return { json: { file, variant: VARIANT[item.json.op] || item.json.op, assetId: out.assetId, publicUrl: out.publicUrl, width: out.width, height: out.height, jobId: item.json.jobId } }; }); Append Manifest Row appends the rows and matches fields to columns by name.
Step 4: Price it before you run it
Open Cut-out (remove_bg) and turn on Dry Run. Activate the workflow and drop one photo into the folder. With Dry Run on, that node creates and charges nothing, and its output is the price:
{ "dryRun": true, "estimate": { "estimatedCredits": …, "creditBalance": …, "sufficientCredit": true } } Credits are a fixed 10,000 to the dollar, so estimatedCredits divided by 10,000 is the price in USD. The price of each background-removal model is listed on imagestep.dev/pricing. To use a different one, set Options → Model on the Cut-out node.
The two deterministic branches do run on this pass (on the Free plan they use two of the month's 200 ops). So the sheet gets their two rows, plus a cut-out row with nothing but the file name. Delete those rows, then turn Dry Run off.
Step 5: Run it
Drop a photo into the folder. Within a minute or so the sheet has three new rows. For examples/product.jpg they look like this, each with its own assetId, publicUrl and jobId:
| file | variant | width | height |
|---|---|---|---|
| product.jpg | cutout | 1024 | 1024 |
| product.jpg | packshot | 1264 | 1264 |
| product.jpg | web | 1024 | 1024 |
Two of those numbers are worth a second look:
- The packshot is 1264 px: the 1024 px photo plus 120 px of white on each side.
- The web size is 1024 px, not 1200:
withoutEnlargement: truemeans resize never upscales. A 3000 px photo comes out 1200 px wide, and a smaller one is left as it is.
The cut-out is a PNG with an alpha channel, the same size as the input.
This is what a real run of the recipe made from that photo (thumbnails). From top to bottom: the upload, the cut-out, the packshot and the web size.
Before you point the folder at a thousand photos, run it on one. The template's ImageStep nodes are checked against the node's field definitions by the recipes' tests, but your Drive folder and your sheet are your own setup.
The same pipeline as a script
For a cron job or CI, or to try it without n8n, the recipe folder has trio.mjs: the same pipeline as a short script on the JavaScript SDK. You need Node 20+ and pnpm.
git clone https://github.com/imagestep/imagestep-sdk.git cd imagestep-sdk/recipes pnpm install export IMAGESTEP_API_KEY=is_sk_… node product-trio/trio.mjs examples/product.jpg --dry-run # uploads, prices the AI op, stops node product-trio/trio.mjs examples/product.jpg At its core is one upload and three jobs started together:
const source = await client.assets.upload(file, { collection, wait: true }); const wait = { timeoutMs: 240_000 }; const [cutout, packshot, web] = await Promise.all([ client.ops.removeBg(source.id, { collection, wait }), client.ops.pad(source.id, { top: pad, bottom: pad, left: pad, right: pad, background: "#ffffff" }, { collection, wait }), client.ops.resize(source.id, { width, fit: "inside", withoutEnlargement: true }, { collection, wait }) ]); Then it publishes each output with client.assets.publish and prints one JSON line per variant, plus a summary line a program can read:
uploaded product.jpg → ast_… (1024×1024) dry run: remove_bg … credits · pad and resize are deterministic ops, priced by plan (balance …) {"variant":"cutout","assetId":"ast_…","publicUrl":"https://cdn.imagestep.dev/…","width":1024,"height":1024,"jobId":"job_…"} {"variant":"packshot","assetId":"ast_…","publicUrl":"https://cdn.imagestep.dev/…","width":1264,"height":1264,"jobId":"job_…"} {"variant":"web","assetId":"ast_…","publicUrl":"https://cdn.imagestep.dev/…","width":1024,"height":1024,"jobId":"job_…"} {"recipe":"product-trio","sourceAssetId":"ast_…","variants":[…]} Options: --pad 160, --width 1600, --collection shop. IMAGESTEP_BASE_URL points the script at another API host.
Branch or chain?
Branches are the right shape when the variants don't depend on each other. If you want the packshot padded around the cut-out rather than the raw photo, you need a chain instead. Pass the cut-out's assetId to a second pad, or send both steps as one job (steps: [remove_bg, pad]) and save it as a preset once it deserves a name. The marketplace cut-out recipe is that chain, finished.
When it goes wrong
- A branch's output has no
publicUrl, or bytes instead of an asset id: Store Result is off on that node. Turn it on, together with Publish Outputs. -
asset_not_found(404): the branch isn't getting the upload's id. Check that Input is Asset IDs, that the field is{{ $json.assetId }}, and that the branch is wired straight from Upload Once. - The credential test is red: the key is wrong or revoked, or Base URL was changed. It should be
https://api.imagestep.dev. -
insufficient_credit(402): the AI op costs more than your balance. The job is refused when you submit it, before anything runs, and the error carries a top-up link. The dry run'ssufficientCredittells you in advance. - The wait times out: the job keeps running. Raise Wait Seconds, or turn off Wait for Result and collect the result later with Job → Wait or the ImageStep Trigger. The trigger is a signed webhook and needs n8n on a public
https://URL, so a local n8n onlocalhost:5678needsWEBHOOK_URLset to a tunnel. - Retry or not? Every ImageStep error carries a
retryableflag.rate_limited(429),provider_unavailable(503) andinternal_error(500) are worth retrying.invalid_paramandinsufficient_creditare not: a retry fails the same way.
Where to go from here
- The recipes folder has more pipelines, each as a Node script and an n8n template. Among them: a social carousel with the same character on every card, one product placed in new scenes at every ad size, HTML/CSS cards rendered from sheet rows, and phone photos filed by EXIF date and GPS.
- Docs, live prices and the pictures from real runs are on imagestep.dev.
Next in this series: the same character on every AI-generated image, from a Google Sheet.





