Back to Blog

Drive-Backed Asset Catalogs: Avatars and Products Without Code Deploys

By · 6 min read
Google Drive Asset Management TypeScript Next.js Product Engineering

Every new avatar on the AI ad platform used to require a pull request. Product hero images, presenter headshots, brand metadata — all hardcoded in TypeScript constants, reviewed in CI, deployed through the normal release train. When the creative team wanted to add a sunscreen SKU on Tuesday afternoon, they filed a ticket and waited until Thursday's deploy.

The fix was not a CMS. It was a Google Drive folder.

We built Drive-backed runtime catalogs for avatars and products: a JSON index file alongside media assets in a shared folder, read at request time with short TTL caching. Editors upload photos that auto-save as reusable avatars. Product managers drop new SKUs into a products subfolder. The generation pipeline reads the catalog on every run — no code deploy required.

A catalog is an index plus media, not a database migration. Drive gives non-engineers write access with audit history built in.

The hardcoded asset problem

Early platform versions stored assets inline:

// Before: every new avatar was a code change
const AVATARS = [
  { id: 'presenter-1', name: 'Alex', imageUrl: '/assets/avatars/alex.jpg' },
  { id: 'presenter-2', name: 'Jordan', imageUrl: '/assets/avatars/jordan.jpg' },
] as const;

const PRODUCTS = [
  { id: 'product-a', name: 'Serum X', heroUrl: '/assets/products/serum-x.png' },
] as const;

This pattern fails for three reasons on a production creative tool:

  • Velocity — creative ops cannot move at the speed of deploys
  • Uploaded photos — script-first UGC runs let editors upload custom headshots that should become reusable, not one-off blobs
  • Multi-product — the same generation flow must work for any product in the catalog, not one hardcoded SKU
Approach Non-engineer writable? Version history? Deploy required?
Hardcoded constants No Git only Yes
Admin CMS panel Yes (with login) DB audit log No (but build the CMS)
Drive-backed catalog Yes (shared folder) Drive revision history No

Folder structure convention

Two environment variables anchor the system: AVATARS_DRIVE_FOLDER_ID and PRODUCTS_DRIVE_FOLDER_ID. Each root folder follows the same internal layout:

AVATARS_DRIVE_FOLDER_ID/
├── index.json                 # catalog metadata
├── avatars/
│   ├── presenter-alex/
│   │   ├── headshot.jpg
│   │   └── metadata.json    # optional per-avatar overrides
│   ├── presenter-jordan/
│   │   └── headshot.jpg
│   └── uploaded-20260721-a3f2/
│       └── photo.jpg          # auto-saved from editor upload

PRODUCTS_DRIVE_FOLDER_ID/
├── index.json
├── products/
│   ├── sunscreen-spf50/
│   │   ├── hero.png
│   │   ├── label-closeup.jpg
│   │   └── metadata.json
│   └── vitamin-c-serum/
│       └── hero.png

The index file is the contract. Media files alone are not enough — the index carries display names, tags, conditioning weights, and enabled flags.

interface AvatarIndexEntry {
  id: string;
  displayName: string;
  photoDriveFileId: string;
  source: 'catalog' | 'uploaded';
  conditioningStrength?: number;
  enabled: boolean;
  createdAt: string;
  createdBy?: string;
}

interface AvatarCatalog {
  version: number;
  updatedAt: string;
  avatars: AvatarIndexEntry[];
}

Runtime catalog loader

The Next.js API route loads catalogs through a cached Drive provider. Index JSON parses first; photo URLs resolve lazily via Drive file IDs.

class DriveCatalogProvider<T extends CatalogIndex> {
  constructor(
    private folderId: string,
    private cacheTtlMs: number = 60_000,
  ) {}

  async loadIndex(): Promise<T> {
    const cached = cache.get(this.folderId);
    if (cached && Date.now() - cached.fetchedAt < this.cacheTtlMs) {
      return cached.index;
    }
    const indexFile = await drive.files.get({
      fileId: await this.resolveIndexFileId(),
      alt: 'media',
    });
    const index = JSON.parse(indexFile.data as string) as T;
    cache.set(this.folderId, { index, fetchedAt: Date.now() });
    return index;
  }

  async resolveMediaUrl(fileId: string): Promise<string> {
    return driveMediaUrl(fileId, { ttl: 3600 });
  }
}

Sixty-second TTL on the index balances freshness against Drive API quota. Individual media URLs get longer-lived signed links because they are immutable per file revision.

Auto-save uploaded photos as reusable avatars

Script-first UGC runs encourage custom headshot uploads. Before auto-save, those photos lived in a run-scoped temp bucket — usable once, invisible to other editors. The auto-save flow:

  1. Editor uploads photo during character selection step
  2. Pipeline validates face detection and minimum resolution
  3. Photo copies to avatars/uploaded-{timestamp}-{hash}/ in the Drive folder
  4. Index appends a new entry with source: 'uploaded'
  5. Avatar appears in the global picker on next catalog refresh
async function persistUploadedAvatar(
  upload: Buffer,
  meta: { editorId: string; runId: string },
): Promise<AvatarIndexEntry> {
  const avatarId = `uploaded-${Date.now()}-${hashShort(upload)}`;
  const folderPath = `avatars/${avatarId}`;

  const fileId = await driveUpload({
    folderId: AVATARS_DRIVE_FOLDER_ID,
    path: `${folderPath}/photo.jpg`,
    content: upload,
  });

  const entry: AvatarIndexEntry = {
    id: avatarId,
    displayName: `Uploaded ${formatDate(new Date())}`,
    photoDriveFileId: fileId,
    source: 'uploaded',
    conditioningStrength: 0.82,
    enabled: true,
    createdAt: new Date().toISOString(),
    createdBy: meta.editorId,
  };

  await appendToIndex(AVATARS_DRIVE_FOLDER_ID, entry);
  return entry;
}

Index updates use optimistic locking on the version field. Concurrent uploads retry on version mismatch — rare in practice because creative teams do not upload fifty avatars simultaneously, but necessary for correctness.

Multi-product catalog and product-agnostic scripts

Adding products at runtime was the second catalog. The initial rollout seeded eight avatars and three products from the AI ad platform's existing SKU library. Subsequent additions — three presenter avatars, three more products — happened entirely through Drive folder updates.

Multi-product Phase 1 made scripts product-agnostic. Instead of hardcoding "this serum changed my skin" in prompt templates, scripts reference product placeholders resolved at plan time:

function resolveScriptPlaceholders(
  script: string,
  product: ProductCatalogEntry,
): string {
  return script
    .replace(/\{\{product_name\}\}/g, product.displayName)
    .replace(/\{\{product_category\}\}/g, product.category)
    .replace(/\{\{product_claim\}\}/g, product.primaryClaim ?? '');
}

// Editor writes:
// "I've been using {{product_name}} for two weeks and {{product_claim}}"
// Resolves differently per productId without script rewrite

The product catalog entry carries everything the planner needs:

Field Used by Example
heroDriveFileId Scene generation (product lock) Bottle on white background
displayName Script placeholder + UI picker "Daily SPF 50"
category LLM scene planning context "sunscreen"
primaryClaim Script placeholder "no white cast"
enabled Picker visibility false for deprecated SKUs

Frontend integration: picker UX

The avatar picker reads the catalog API and renders larger character previews with loading skeletons while Drive URLs resolve. Product picker shows inline hero preview on hover. Both pickers poll the catalog endpoint with stale-while-revalidate semantics — show cached catalog immediately, refresh in background.

A stuck deploy banner bug surfaced during catalog rollout: the frontend cached an empty catalog when Drive was momentarily unreachable, then never refreshed. The fix added explicit cache busting on catalog version mismatch and a manual refresh control for ops.

Operational workflow for creative teams

Adding a new product without engineering:

  1. Create subfolder under products/ with hero image and optional label closeup
  2. Add entry to index.json with metadata fields
  3. Set enabled: true
  4. Wait up to 60 seconds for cache TTL, or trigger manual cache bust via admin endpoint
  5. Product appears in picker — same generation flow, product-agnostic script templates

Drive revision history provides rollback. If a bad hero image ships, ops reverts the file in Drive — no git revert, no deploy.

Tradeoffs and limits

Benefit Cost
No deploy for asset changes Drive API quota and latency on cold cache
Non-engineer writable Index JSON can be hand-edited incorrectly — schema validation required
Auto-save from uploads Catalog grows unbounded — periodic cleanup policy needed
Revision history via Drive No relational queries — filter/search is client-side over index

We validate index JSON against Zod schemas on every load. Invalid entries log errors and skip gracefully — one bad product entry does not crash the entire picker.

What I'd do differently

I would ship index schema validation before the first catalog migration, not after a hand-edited JSON typo took down the product picker for twenty minutes.

I would also add a lightweight admin UI for index edits eventually. Drive is the right storage layer, but editing JSON in a shared folder is error-prone for non-technical users. The catalog provider abstraction makes a future admin panel a thin wrapper — the Drive folder remains source of truth.

The pattern

Drive-backed catalogs work when:

  • Assets change frequently but fit a flat index structure
  • Non-engineers need write access without a full CMS build
  • Media files are immutable per revision (Drive handles versioning)
  • Read volume is high but write volume is low (cache-friendly)

For the AI ad platform, avatars and products checked every box. The same pattern could extend to music beds, brand palettes, or disclaimer templates — any catalog where deploy latency is the enemy and relational queries are overkill.

Related Articles