DEV Community

Connie Leung
Connie Leung

Posted on

Generating Multiple Images with NanoBanana and Firebase AI Logic in Angular

I have recently explored the capabilities of the gemini-2.5-flash-image model (a.k.a. NanoBanana), writing prompts to either generate new images or edit images to create new ones.

So far, my use cases only need NanoBanana to create an image. However, my new "Visual Story" feature requires the model to create multiple sequential images to develop a story, tutorial, process or timeline.

My first incorrect attempt was to update the candidateCount to output multiple images. However, Firebase AI Logic issued an error message that the model does not support multiple candidates.

The workaround is to construct step prompts and call the the model to generate an image for each step prompt.

Here is how I modified DevFest Hong Kong 2025 Angular and Firebase AI Logic demo to implement the "Visual Story" feature.


The Initial Challenge: candidateCount Limitation

My initial thought was to simply configure the Firebase AI Logic request to return multiple images using the candidateCount parameter in the GenerationConfig.

In my code, I set up the default configuration with the NanoBanana model and requested four candidates:

const { app, geminiModelName = 'gemini-2.5-flash-image' } = firebaseConfig;

const DEFAULT_CONFIG: ModelParams = {
  model: geminiModelName, // NanoBanana
  generationConfig: {
    responseModalities: ResponseModality.IMAGE,
    candidateCount: 4, // Attempting to get 4 images
  },
};
Enter fullscreen mode Exit fullscreen mode

The provideFirebase provider used the NANO_BANANA_MODEL token to inject a GenerativeModel function that used the NanoBanana model to create a maximum of 4 images.

const firebaseApp = initializeApp(app);
const ai = getAI(firebaseApp, { backend: new GoogleAIBackend() });

export function provideFirebase() {
    return makeEnvironmentProviders([
        {
            provide: NANO_BANANA_MODEL,
            useFactory: () => getGenerativeModel(ai, DEFAULT_CONFIG),
        }
    ]);
}
Enter fullscreen mode Exit fullscreen mode

However, when I tried to generate a four-part comic strip, the API returned an error:

Text prompt: Create a 4-part story where the dog finds a bone and eats it.
Enter fullscreen mode Exit fullscreen mode
Error: multiple candidates used disallowed for this model`
Enter fullscreen mode Exit fullscreen mode

I was surprised and reverted the candidateCount to 1. When I reran the same prompt, the model generated a comic strip with four parts that was not what I intended.

I needed a different approach to produce a set of images that told a coherent, sequential story.

The Solution: Building a "Visual Story" Workaround

Instead of forcing multiple candidates, I implemented a client-side workaround to sequentially call the API multiple times, once for each image, using a specialized prompt for each step.

Build a Visual Story form

I created a custom Angular Form component with a prompt text area, number of images, type, style, and transition dropdowns.

// VisualStoryFormComponent

<form class="flex flex-col sm:flex-row gap-4 px-4"
  id="form"
  name="form"
  (ngSubmit)="onGenerateClick()"
>
  @let args = promptArgs();
  <div class="flex-grow">
    <textarea
      id="promptHistory"
      name="promptHistory"
      [(ngModel)]="args.userPrompt"
      [rows]="4"
      (keydown)="onEnterPress($event)">
    </textarea>

    @let sequenceArgs = args.args;
    <div class="grid grid-cols-1 sm:grid-cols-2 gap-2">
      <div class="flex text-left">
        <label for="image-count">Image count</label>
        <select id="image-count" name="image-count" [(ngModel)]="sequenceArgs.numberOfImages">
          @for (count of numOfImagesList(); track count) {
            <option [ngValue]="count">{{ count }}</option>
          }
        </select>
      </div>
      <div>
        <label for="type">Type</label>
        <select id="type" name="type" [(ngModel)]="sequenceArgs.type" class="ai-dropdown">
          @for (type of types(); track type) {
            <option [ngValue]="type">{{ type }}</option>
          }
        </select>
      </div>
      <div>
        <label for="style">Style</label>
        <select id="style" name="style" [(ngModel)]="sequenceArgs.style">
          @for (style of styleList(); track style) {
            <option [ngValue]="style">{{ style }}</option>
          }
        </select>
      </div>
      <div>
        <label for="transition">Transition</label>
        <select id="transition" name="transition" [(ngModel)]="sequenceArgs.transition">
          @for (transition of transitionList(); track transition) {
            <option [ngValue]="transition">{{ transition }}</option>
          }
        </select>
      </div>
    </div>
  </div>
  <div>
    <button type="submit" [disabled]="isGenerationDisabled()">Generate</button>
  </div>
</form>
Enter fullscreen mode Exit fullscreen mode
  1. Number of images: 2, 4, 6, or 8 images.
  2. Type: story, process, tutorial, or timeline.
  3. Style: consistent, evolving.
  4. Transition: smooth, dramatic, fade

Users can type a prompt, select number of images, type, style, and transition, and click the button to generate the images.

Caveat: When generating multiple images, partial success may occur and users receive less images than requested.

Define Sequential Prompts

The core of the workaround lies in two key functions within my VisualStoryService.

1. The Looping Logic (buildStepPrompts)

This function executes a loop for the requested number of images (e.g., 8), calling a helper function to create a unique prompt for each step.

// Visual Story Type

export type VisualStoryArgs = {
  style: 'consistent' | 'evolving';
  numberOfImages: number;
  transition: 'smooth' | 'dramatic' | 'fade';
  type: 'story' | 'process' | 'tutorial' | 'timeline';
}

export type VisualStoryGenerateArgs = {
  args: VisualStoryArgs;
  userPrompt: string;
}
Enter fullscreen mode Exit fullscreen mode
// VisualStoryService

buildStepPrompts(genArgs: VisualStoryGenerateArgs): string[] {
    const { userPrompt, args } = genArgs;
    const currentPrompt = userPrompt.trim();

    if (!currentPrompt) {
      return []; // Button should be disabled, but this is a safeguard.
    }

    const stepPrompts: string[] = [];

    for (let i = 0; i < args.numberOfImages; i++) {
      const storyPrompt = this.buildStoryPrompt({ userPrompt: currentPrompt, args }, i + 1);
      stepPrompts.push(storyPrompt);
    }

    return stepPrompts;
}
Enter fullscreen mode Exit fullscreen mode

2. The Contextual Prompt Builder (buildStoryPrompt)

This helper method method extracts user prompt, number of images, style, transition, and type from the argument.

The prompt first begins with the current step number out of the total number of images.

When step is greater than 1, the prompt applies dramatic, smooth or fade transition from the previous step.

It also adds description of the visualization to the prompt based on type such as educational diagram and narrative.

// VisualStoryService

private buildStoryPrompt(genArgs: VisualStoryGenerateArgs, stepNumber: number): string {
    const { userPrompt, args } = genArgs;
    const { numberOfImages, style, transition, type } = args;
    let fullPrompt = `${userPrompt}, step ${stepNumber} of ${numberOfImages}`;

    // Add context based on type
    switch (type) {
      case 'story':
        fullPrompt += `, narrative sequence, ${style} art style`;
        break;
      case 'process':
        fullPrompt += `, procedural step, instructional illustration`;
        break;
      case 'tutorial':
        fullPrompt += `, tutorial step, educational diagram`;
        break;
      case 'timeline':
        fullPrompt += `, chronological progression, timeline visualization`;
        break;
    }

    // Add transition context
    if (stepNumber > 1) {
      fullPrompt += `, ${transition} transition from previous step`;
    }

    return fullPrompt;
  }
Enter fullscreen mode Exit fullscreen mode

Sequential Images

The VisualStoryComponent passes the sequential prompts to the GenMediaComponent to generate the images.

// VisualStoryComponent

<app-gen-media [genMediaInput]="genMediaInput()">
    <p class="text-sm">Your generated {{ promptArgs().args.type}} will appear here. Please be patient.</p>
</app-gen-media>
Enter fullscreen mode Exit fullscreen mode

The GenMediaComponent invokes GenMediaService's generateImages method to generate the images and display the base64 inline data in the underlying HTML image elemments.

// GenMediaComponent

genMediaInput = input<GenMediaInput>();

imagesResource = resource({
    params: () => this.genMediaInput(),
    loader: ({ params }) => {
      const { userPrompt, prompts = [], imageFiles = [] } = params;
      const multiPrompts = prompts.length ? prompts : [userPrompt];
      return this.genMediaService.generateImages(multiPrompts, imageFiles);
    },
    defaultValue: [] as ImageResponse[],
 });
Enter fullscreen mode Exit fullscreen mode

The HTML passes the base64 inline data and image id to the ImageViewer component to display the image in an HTML image element.

// GenMediaComponent

<div [class]="responsiveLayout">
  @for (imageResponse of imageResponses; track imageResponse.id) {
    <app-image-viewer class="block mt-4"
      [url]="imageResponse.inlineData"
      [id]="imageResponse.id"
      (imageAction)="handleAction($event)"
    />
  }
</div>
Enter fullscreen mode Exit fullscreen mode

The GenMediaService's generateImages method calls the private generateImage method to generate a base64 inline data. Finally, the array stores the successful images are and it is returned.

// GenMediaService
private readonly firebaseService = inject(FirebaseService);

private async generateImage(prompt: string, imageFiles: File[]): Promise<ImageResponse | undefined> {
    if (!prompt || !prompt.trim()) {
      return undefined;
    }

    const trimmedPrompt = prompt.trim();

    try {
      return await this.firebaseService.generateImage(trimmedPrompt, imageFiles);
    } catch (e: unknown) {
        // error handling
    }
  }

  async generateImages(prompts: string[], imageFiles: File[]): Promise<ImageResponse[]> {
    if (!prompts?.length) {
      return [];
    }

    const imageResponses: ImageResponse[] = [];

    for (let i = 0; i < prompts.length; i=i+1) {
      try {
        const imageResponse = await this.generateImage(prompts[i], imageFiles);
        if (imageResponse) {
          imageResponses.push(imageResponse);
        }
      } catch (e: unknown) {
        // error handling
      }
    }

    return imageResponses.map((imageResponse, index) => ({
      ...imageResponse,
      id: index,
    }));
}
Enter fullscreen mode Exit fullscreen mode

Summary

By moving the logic for generating multiple images from a single API parameter (candidateCount) to a structured, iterative client-side approach, I successfully generated a sequence of up to 8 images that flow naturally from one to the next, perfect for visual storytelling.

Resources

Generate multiple images by Gemini nanobanana model
Generate Story Sequence
Generate and edit image in Firebase AI Logic

Top comments (0)