DEV Community

Knipper Mccarville
Knipper Mccarville

Posted on

From Prompt Box to Stateful AI Workflow: Building an AI Product Ad Studio

Most AI creative products start with the same interface: a large, empty prompt box. A stateful AI workflow needs more than that.

The prompt box is useful for experimentation. It is a weak foundation for a repeatable product.

While building Photo2Ads, an AI product-ad studio, I kept running into the same product and engineering problem: the model could generate an output quickly, but the application kept asking the user to reconstruct the project around it.

The product image, creative direction, model capabilities, mode-specific settings, and provenance of the result all needed to survive longer than one request.

This article covers the architecture decisions that moved the product from a prompt wrapper toward a stateful AI workflow:

  • Separate Image and Video draft state
  • localStorage for serializable settings
  • IndexedDB for real File objects
  • Declarative capability contracts for generation models
  • Provenance-aware examples that can seed the workbench
  • Progressive enhancement when browser storage is unavailable

The Photo2Ads image-ad workbench

1. Treat the prompt as an instruction, not the project

A prompt is only one part of a product-ad generation request.

The actual project also contains:

  • Product reference images
  • An optional person reference
  • Creative style
  • Aspect ratio and resolution
  • Image or video model
  • Video hook and setting
  • Duration and audio preference
  • The relationship between inputs and outputs

If the application stores only the prompt, switching modes or refreshing the page destroys most of the user's work.

The first important decision was to model Image and Video as separate drafts.

interface ProductAdSettingsDraft {
  image: {
    prompt: string;
    styleId?: string;
    model: ImageModel;
    aspectRatio: ImageAspectRatio;
    resolution: ImageResolution;
    imageCount: number;
  };
  video: {
    prompt: string;
    styleId?: string;
    hook: VideoHook;
    setting: VideoSetting;
    model: VideoModel;
    aspectRatio: VideoAspectRatio;
    resolution: VideoResolution;
    duration: number;
    audioEnabled: boolean;
  };
}
Enter fullscreen mode Exit fullscreen mode

This looks obvious in retrospect, but it prevents a surprisingly common class of bugs.

When one state object is shared between two modes:

  • Editing a video prompt can overwrite the image prompt
  • A video-only hook leaks into image generation
  • Switching models can leave unsupported settings selected
  • Returning to a mode feels like starting over

The product is shared conceptually, but each mode needs its own working state.

In React, the active state can be selected without merging the underlying drafts:

const [imageProducts, setImageProducts] = useState<ProductReference[]>([]);
const [videoProducts, setVideoProducts] = useState<ProductReference[]>([]);

const productReferences =
  mediaMode === 'video' ? videoProducts : imageProducts;
Enter fullscreen mode Exit fullscreen mode

The UI changes modes. The drafts remain intact.

2. Do not put File objects in localStorage

The settings draft is JSON-serializable, so localStorage is sufficient:

function writeSettingsDraft(value: ProductAdSettingsDraft) {
  try {
    localStorage.setItem(SETTINGS_KEY, JSON.stringify(value));
  } catch {
    // Storage may be disabled. The in-memory workflow still works.
  }
}
Enter fullscreen mode Exit fullscreen mode

Product and person references are different.

An uploaded asset is not just metadata. The browser may need the actual File after a refresh so it can:

  • Render a new preview URL
  • Upload the reference when generation starts
  • Preserve the original filename and MIME type
  • Move between Image and Video without asking for the file again

Serializing a File to JSON loses the binary data. Persisting only a blob: URL also fails because object URLs belong to the current document lifecycle.

IndexedDB can store structured-clone-compatible values, including File and Blob objects.

A minimal object store is enough:

const DATABASE_NAME = 'photo2ads-product-ad-drafts';
const STORE_NAME = 'drafts';
const ASSETS_KEY = 'workbench-assets-v1';

function openDraftDatabase(): Promise<IDBDatabase> {
  return new Promise((resolve, reject) => {
    const request = indexedDB.open(DATABASE_NAME, 1);

    request.onupgradeneeded = () => {
      const db = request.result;
      if (!db.objectStoreNames.contains(STORE_NAME)) {
        db.createObjectStore(STORE_NAME);
      }
    };

    request.onsuccess = () => resolve(request.result);
    request.onerror = () => reject(request.error);
  });
}
Enter fullscreen mode Exit fullscreen mode

The asset draft keeps Image and Video references separate:

interface ProductAdAssetsDraft {
  imageProductReferences?: ProductReference[];
  videoProductReferences?: ProductReference[];
  imageAvatar?: AvatarReference;
  videoAvatar?: AvatarReference;
}
Enter fullscreen mode Exit fullscreen mode

Writing it is a single IndexedDB transaction:

async function writeAssetsDraft(value: ProductAdAssetsDraft) {
  const db = await openDraftDatabase();

  await new Promise<void>((resolve, reject) => {
    const tx = db.transaction(STORE_NAME, 'readwrite');
    tx.objectStore(STORE_NAME).put(value, ASSETS_KEY);

    tx.oncomplete = () => {
      db.close();
      resolve();
    };

    tx.onerror = () => {
      db.close();
      reject(tx.error);
    };
  });
}
Enter fullscreen mode Exit fullscreen mode

On hydration, a persisted File receives a new object URL:

function restoreReference(
  reference: ProductReference
): ProductReference | undefined {
  if (reference.file instanceof File) {
    return {
      ...reference,
      previewUrl: URL.createObjectURL(reference.file),
    };
  }

  return reference.fileId && !reference.previewUrl.startsWith('blob:')
    ? reference
    : undefined;
}
Enter fullscreen mode Exit fullscreen mode

This is also why object URL cleanup matters:

function revokeObjectUrl(url?: string) {
  if (url?.startsWith('blob:')) {
    URL.revokeObjectURL(url);
  }
}
Enter fullscreen mode Exit fullscreen mode

Without cleanup, repeatedly replacing reference images can leak memory during a long editing session.

3. Hydrate before you autosave

Autosaving a draft introduces a race condition.

If persistence effects run before the old draft has been hydrated, the initial empty React state can overwrite the stored draft.

The workbench uses an explicit hydration flag:

const [draftHydrated, setDraftHydrated] = useState(false);

useEffect(() => {
  let cancelled = false;

  async function hydrateDraft() {
    const settings = readSettingsDraft();
    const assets = await readAssetsDraft();

    if (cancelled) return;

    restoreSettings(settings);
    restoreAssets(assets);
    setDraftHydrated(true);
  }

  void hydrateDraft();

  return () => {
    cancelled = true;
  };
}, []);
Enter fullscreen mode Exit fullscreen mode

Every persistence effect checks the flag:

useEffect(() => {
  if (!draftHydrated) return;

  const timeout = window.setTimeout(() => {
    writeSettingsDraft(buildCurrentSettings());
  }, 120);

  return () => window.clearTimeout(timeout);
}, [draftHydrated, imagePrompt, videoPrompt, model, videoModel]);
Enter fullscreen mode Exit fullscreen mode

The short debounce avoids writing on every keystroke while keeping the draft responsive.

The rule is simple:

Never let default client state write to persistence before restoration has completed.

4. Model capabilities belong in data, not scattered conditionals

Video generation models do not support the same inputs.

They can differ by:

  • Duration
  • Resolution
  • Aspect ratio
  • Audio support
  • Number of reference images
  • Pricing tier and credit cost

If these rules are spread across UI components and API handlers, they drift.

A declarative model contract is easier to reason about:

interface VideoModelConfig {
  id: VideoModel;
  displayName: string;
  supportedDurations: number[];
  supportedResolutions: VideoResolution[];
  supportedAspectRatios: VideoAspectRatio[];
  supportsAudio: boolean;
  audioAlwaysOn?: boolean;
  maxReferenceImages: number;
}
Enter fullscreen mode Exit fullscreen mode

The UI reads this contract to show valid options. The validation layer reads the same contract before a request is submitted.

function validateVideoInput(input: VideoInput, config: VideoModelConfig) {
  if (!config.supportedDurations.includes(input.duration)) {
    throw new Error(`${config.displayName} does not support that duration.`);
  }

  if (!config.supportedResolutions.includes(input.resolution)) {
    throw new Error(`${config.displayName} does not support that resolution.`);
  }

  if (input.audioEnabled && !config.supportsAudio) {
    throw new Error(`${config.displayName} does not support generated audio.`);
  }

  if (input.referenceImageCount > config.maxReferenceImages) {
    throw new Error(
      `${config.displayName} supports up to ` +
        `${config.maxReferenceImages} reference images.`
    );
  }
}
Enter fullscreen mode Exit fullscreen mode

Reference limits become especially important when a person reference consumes one of the available slots.

const productReferenceLimit = Math.max(
  1,
  model.maxReferenceImages - (hasPersonReference ? 1 : 0)
);
Enter fullscreen mode Exit fullscreen mode

The interface should communicate this before generation, not wait for a remote provider to reject the request.

5. Preserve provenance in examples

An AI showcase is usually treated as marketing content.

It becomes more useful when it is also a source of executable state.

The showcase data in Photo2Ads keeps the result connected to the prompt, creative style, and any references that genuinely belong to that case:

interface ShowcaseExample {
  slug: string;
  name: string;
  prompt: string;
  styleId: string;
  poster: string;
  video: string;
  productReference?: {
    id: string;
    name: string;
    imageUrl: string;
  };
  avatarReference?: {
    id: string;
    name: string;
    imageUrl: string;
  };
}
Enter fullscreen mode Exit fullscreen mode

A real product reference, person reference, and generated frame

Clicking Recreate seeds the workbench instead of navigating to an empty generator:

function applyExample(example: ShowcaseExample) {
  workbench.seed({
    mediaMode: 'video',
    prompt: example.prompt,
    styleId: example.styleId,
    productReference: example.productReference ?? null,
    avatarReference: example.avatarReference ?? null,
  });
}
Enter fullscreen mode Exit fullscreen mode

The null values are important.

If an example was created without a bound product or person reference, the application should not invent one just to make the handoff look complete.

That gives the user a more honest contract:

  • Existing inputs are transferred
  • Missing inputs remain missing
  • The exact prompt remains inspectable
  • The user can understand what must be replaced

This turns a gallery into a form of executable documentation.

6. Make persistence a progressive enhancement

Browser storage can fail.

Private browsing, storage policies, quota limits, and browser behavior can make localStorage or IndexedDB unavailable.

The generation flow should not become unusable because draft persistence failed.

In Photo2Ads, storage helpers catch failures and return undefined. The current in-memory session continues to work.

async function readAssetsDraft<T>(): Promise<T | undefined> {
  try {
    const db = await openDraftDatabase();
    return await readValue<T>(db, ASSETS_KEY);
  } catch {
    return undefined;
  }
}
Enter fullscreen mode Exit fullscreen mode

This creates a useful hierarchy:

  1. In-memory state is required for the current interaction.
  2. Browser persistence improves continuity.
  3. Remote storage begins only when the user submits a generation request.

The draft system should reduce lost work without becoming a prerequisite for using the tool.

7. Keep review inside the workflow

State and provenance are not only convenience features.

They also make AI output easier to review.

Product-ad models can:

  • Misread labels
  • Change packaging
  • Distort logos
  • Alter brand colors
  • Invent visual claims
  • Produce inconsistent products across video frames

Three reference-to-result pairs from the Photo2Ads showcase

Keeping references close to results gives the reviewer an immediate comparison. Keeping the prompt and settings attached makes unexpected behavior easier to diagnose.

Human review is not evidence that the workflow failed. It is a required stage of a system that creates advertising for real products.

What I would reuse in another generative product

These patterns are not specific to product ads.

If I were building another stateful AI tool, I would reuse the same principles:

  1. Identify the durable object.

    What should remain stable while prompts and outputs change?

  2. Separate mode-specific state.

    Do not let one workflow silently overwrite another.

  3. Choose persistence by data type.

    JSON fits localStorage; binary assets belong in IndexedDB or remote storage.

  4. Hydrate before autosaving.

    Prevent empty initial state from overwriting a real draft.

  5. Describe model capabilities as data.

    Let the UI and validator share the same contract.

  6. Keep provenance attached to outputs.

    A result without its inputs is difficult to reproduce or trust.

  7. Fail softly when persistence is unavailable.

    Continuity is an enhancement; the core interaction should still work.

The real product is the workflow

It is easy to judge an AI feature by generation latency or the visual quality of its best output.

Users experience everything around that model call:

  • Repeated setup
  • Mode switching
  • Reference limits
  • Lost uploads
  • Invalid combinations
  • Result review
  • Reproduction and handoff

A fast model does not automatically create a fast product.

The larger lesson from building Photo2Ads is that the prompt box should not be the center of the architecture. The durable object should be.

For product advertising, that object is the product.

You can explore the working implementation and real examples at photo2ads.com.

If you are building a generative tool, what information does your application keep forcing users to enter again?

Top comments (0)