Ask an image model for "our mascot: a woman in her thirties with short silver hair, a navy rain jacket and a mustard beanie" forty times and you get forty cousins. The drift comes in two halves:
- Geometry. Words don't pin down a face, the proportions or the exact cut of a jacket. Pictures do.
- Words. Every prompt describes the character a little differently, and three weeks later someone edits the prompt. Whatever a picture can't say on its own (the colour under different light, the material, the text on a label) drifts along with the words.
This tutorial builds a carousel pipeline that closes both halves. Every row you add to a Google Sheet becomes four images with the same character on all of them, published, with the four URLs written back into the row.
Google Sheet: new row (headline, subline)
→ Build the Prompt Code: the row's scene, with {{subject.hero}} where the character goes
→ Generate 4 Cards ImageStep preset carousel-brand, Count 4
→ Four URLs Per Row Code
→ Write URLs Back the same row: url1 … url4, jobId, status
It runs on ImageStep presets. Disclosure: I build ImageStep. The script and the n8n template are MIT-licensed in the recipes folder of the public SDK repo, and the template is also in n8n's template library.
The idea: a preset with a subject
A preset is a saved, versioned pipeline. This one has a single generate step and one subject:
{
"name": "hero",
"referenceAssetIds": ["ast_…"],
"descriptor": "a woman in her thirties with short silver hair, a navy rain jacket and a mustard beanie"
}
- The reference images pin the geometry. Use one to three (the API takes up to four). They go to the model with every
generatecall made through the preset. - The descriptor pins the words. Each row's prompt writes
{{subject.hero}}instead of describing her again, and ImageStep swaps in the saved descriptor on the server. The run three weeks from now uses exactly the words today's run used. - Change either half and the preset gets a new version. Every job records the version that drew it, so "which settings made this image?" always has an answer.
What you need
- Node 20+ and pnpm, to save the preset once with the recipe script.
- An ImageStep API key from imagestep.dev/keys. New accounts get a one-time $3 welcome credit for AI ops.
-
n8n with a Google Sheets credential and the ImageStep node,
n8n-nodes-imagestep(installed in Step 3). -
One to three clean reference images of the character or product. Front-facing on a plain background works best.
recipes/examples/hero.jpgis one. - One sentence of locked wording for what the pictures can't say (300 characters at most).
Step 1: Save the preset and price a row
git clone https://github.com/imagestep/imagestep-sdk.git
cd imagestep-sdk/recipes
pnpm install
export IMAGESTEP_API_KEY=is_sk_…
node social-carousel/carousel.mjs --refs examples/hero.jpg \
--describe "a woman in her thirties with short silver hair, a navy rain jacket and a mustard beanie" \
--rows social-carousel/rows.example.csv --dry-run
You should see:
reference hero.jpg → ast_…
preset carousel-brand@1 (google/gemini-3.1-flash-image) subject=hero images=ast_…
dry run: … credits per row × 3 rows (balance …)
With --dry-run the script does the free parts and stops before generating anything:
- It uploads the reference images. Uploads are deduplicated by SHA-1, so a re-run reuses them.
- It saves the preset
carousel-brandand pins the version it saved (carousel-brand@1). If the images, the wording or the step have changed since the last run, it saves the next version instead. - It prices one row of four images with a dry run and prints the estimate. Credits are a fixed 10,000 to the dollar.
The preset it saves looks like this (trimmed from carousel.mjs):
{
"name": "Carousel brand character",
"slug": "carousel-brand",
"subjects": [
{
"name": "hero",
"referenceAssetIds": ["ast_…"],
"descriptor": "a woman in her thirties with short silver hair, a navy rain jacket and a mustard beanie"
}
],
"steps": [
{
"op": "generate",
"model": "google/gemini-3.1-flash-image",
"prompt": "{{subject.hero}} in a fresh scene. Keep face, hair, outfit and proportions exactly as in the reference images. Clean composition, no text.",
"parameters": { "aspectRatio": "4:5" }
}
]
}
Each row then becomes one job through the pinned version. The row's prompt replaces the step's prompt:
{
"presetId": "carousel-brand@1",
"prompt": "Up before the sun. three trails that start at dawn. Social carousel card, {{subject.hero}} as the hero, clean background with room for text, 4:5 portrait.",
"count": 4
}
Want the images without n8n? Run the same command without --dry-run. The script generates four cards for each row of rows.example.csv, two rows at a time, publishes them and writes social-carousel/rows.example.out.csv with preset, jobId and url1 to url4 for each row. --model <id> switches the model; it has to be one that takes text plus several images, such as the Gemini image models or GPT-Image.
Step 2: Make the sheet
Create a sheet with this header row: headline, subline, url1, url2, url3, url4, jobId, status. You fill in the first two, and the workflow fills in the rest.
Step 3: Install the node and import the workflow
Install the node. On n8n Cloud and in recent editors, click + on the canvas, search ImageStep, 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 with your key in API Key and leave Base URL at https://api.imagestep.dev.
Import the workflow. Open the template on n8n.io and copy it into your n8n, or download the file:
curl -LO https://raw.githubusercontent.com/imagestep/imagestep-sdk/main/recipes/social-carousel/n8n-template.json
and import it with Import from File in a new workflow's ⋯ menu. Then set:
| Node | What to set |
|---|---|
| Google Sheets Trigger | a Google Sheets Trigger credential, your spreadsheet and sheet |
| Write URLs Back | a Google Sheets credential, the same spreadsheet and sheet |
| Generate 4 Cards | your ImageStep credential |
The Sheets trigger and the Sheets node use two different credential types in n8n, so you connect your Google account once for each.
Step 4: What each node does
Google Sheets Trigger. Event Row Added, polled every minute.
Build the Prompt is a Code node:
return $input.all().map((item) => ({
json: {
row_number: item.json.row_number,
prompt: `${item.json.headline}. ${item.json.subline}. Social carousel card, {{subject.hero}} as the hero, clean background with room for text, 4:5 portrait.`
}
}));
Why a Code node rather than typing the prompt into the ImageStep node? n8n reads {{ }} in a field as its own expression, so a {{subject.hero}} typed there never reaches ImageStep. Inside a JavaScript template literal it is plain text.
Generate 4 Cards is the ImageStep node:
| Field | Value |
|---|---|
| Resource / Operation | Preset / Run |
| Preset | carousel-brand |
| Version |
0 runs the current version; set it to 1 to pin one |
| Input | None (the preset starts from a prompt) |
| Prompt | {{ $json.prompt }} |
| Count | 4 |
| Store Result, Wait for Result, Publish Outputs | on |
| Wait Seconds | 300 |
| Options → Collection | carousel |
Four URLs Per Row is another Code node. It picks the four published URLs out of the job:
return $input.all().map((item) => {
const urls = (item.json.outputs || []).map((o) => o.publicUrl);
return {
json: {
row_number: $('Google Sheets Trigger').item.json.row_number,
jobId: item.json.jobId,
status: item.json.status,
url1: urls[0] || '',
url2: urls[1] || '',
url3: urls[2] || '',
url4: urls[3] || ''
}
};
});
Write URLs Back. Operation Update, matching on row_number, so the URLs land in the row that started the run.
Step 5: Dry run, then a real row
Turn on Dry Run on Generate 4 Cards, activate the workflow and add a row, for example Up before the sun / three trails that start at dawn. The node's output is the price (estimate.estimatedCredits, estimate.sufficientCredit), nothing is generated, and the write-back leaves that row's URLs blank.
Turn Dry Run off and add another row. A minute or two later it has url1 to url4, its jobId and status COMPLETED.
This is what a real run made for the row "Up before the sun" through carousel-brand@1 (thumbnails). First the reference image, then the four cards:
Step 6: Check consistency
This is the acceptance step. Open url1 to url4 of one row side by side, then do the same for a second row. You want the same face, hair, outfit and proportions, and a different scene each time. Score each card against the reference from 1 to 5 and note the preset version. If a batch drifts, add a second reference image or tighten the descriptor. Either change is a new version, which you can compare against the old one.
Pin the version in production
Version 0 runs whichever version is current when the workflow runs. That is handy while you're tuning and risky afterwards: someone saves a new reference image, and tomorrow's cards change. Once you're happy, set Version to the number the script printed (carousel-brand@1 means 1). Each job's output carries presetVersion, so you can always see which version drew a card.
What it costs
generate is an AI op charged per image, so a row costs four images. The price depends on the model (each one is listed on imagestep.dev/pricing), and the dry run prints the exact figure for your settings before anything runs. Uploads, publishing and the preset itself are free.
When it goes wrong
-
invalid_paramonsubjects: the model takes only one image. Pick one that takes text plus several images (the Gemini image models, GPT-Image). The request is refused before anything is charged. -
preset_not_found(404): the preset doesn't exist in this key's account. Run Step 1 with a key from the same account, and check that the Preset field sayscarousel-brand. -
{{subject.hero}}vanishes, or n8n shows an expression error: the prompt was typed into the node's Prompt field. Build it in the Code node, as shown above. -
The character isn't applied: the prompt has to name the subject the preset saved. The script's default subject is
hero, and--subjectchanges it. -
insufficient_credit(402): the job is refused when you submit it, before anything runs, and the error carries a top-up link. - The wait times out: the job keeps running. Raise Wait Seconds, or collect the result later with Job → Wait or the ImageStep Trigger.
-
The URLs don't appear in the sheet: Write URLs Back matches on
row_number. Check that both Sheets nodes point at the same spreadsheet and sheet, and that the column names match exactly.
Where to go from here
- The same mechanism works for a product: product-scenes puts one product into a new scene per line, then makes every ad size of every scene in one job.
- Every recipe, each as a Node script and an n8n template, is in the recipes folder.
- Docs, live prices and the pictures from real runs are on imagestep.dev.
Previously in this series: one product photo → a cut-out, a packshot and a web size, as a parallel n8n pipeline.





Top comments (0)