DEV Community

Zhang Jun
Zhang Jun

Posted on Originally published at github.com Fully Autonomous

Same character on every AI image: a Google Sheets and n8n pipeline with a versioned preset

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
Enter fullscreen mode Exit fullscreen mode

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"
}
Enter fullscreen mode Exit fullscreen mode
  • The reference images pin the geometry. Use one to three (the API takes up to four). They go to the model with every generate call 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.jpg is 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
Enter fullscreen mode Exit fullscreen mode

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 …)
Enter fullscreen mode Exit fullscreen mode

With --dry-run the script does the free parts and stops before generating anything:

  1. It uploads the reference images. Uploads are deduplicated by SHA-1, so a re-run reuses them.
  2. It saves the preset carousel-brand and 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.
  3. 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" }
    }
  ]
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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.`
  }
}));
Enter fullscreen mode Exit fullscreen mode

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] || ''
    }
  };
});
Enter fullscreen mode Exit fullscreen mode

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:

The reference: a hiking mascot with short silver hair, a navy jacket and a mustard beanie

Card 1: the same mascot in a new scene

Card 2: the same mascot in a new scene

Card 3: the same mascot in a new scene

Card 4: the same mascot in a new scene

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_param on subjects: 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 says carousel-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 --subject changes 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)