GAP School Module 02 — Inventory OS Lesson 2.3

For a dealership, the photo is the listing. A buyer makes their first purchase decision based on the photos before they ever read a spec. The image pipeline determines how quickly photos get on the site, how they're protected from being scraped and reposted, how fast they load, and how much storage they consume. Getting this right is not optional.


The situation

Before the pipeline was built, photos lived in a chaotic mix of locations: some in WordPress media, some in Google Drive with no consistent folder structure, some emailed in by salespeople. To add photos to a listing, someone had to download them from wherever they were, resize them manually, upload them one at a time through WordPress admin, then set the featured image. For a unit with 40 photos, that was 20–30 minutes of repetitive work per listing.

There was no watermarking — photos were being scraped and reposted on competitor sites and Craigslist without attribution. There was no CDN — images were served directly from the origin server, and the listing gallery was the primary source of page weight.


What I did

Stock-number-organized Google Drive folders

Every unit gets a Google Drive folder named by stock number: STOCK-12345/. Salespeople drop photos into that folder from their phones. The folder is the photo source of record. Photos never need to be emailed or downloaded manually — they're always in the folder by stock number.

The bulk upload script reads the folder, downloads each image, and uploads it to WordPress via the REST API, attaching it to the inventory CPT post with the matching stock number.

Bulk upload from Drive folder — Apps Script
function bulkUploadImages(stockNumber, postId) { var folder = getOrCreateDriveFolder(stockNumber); var files = folder.getFiles(); var props = PropertiesService.getScriptProperties(); var baseUrl = props.getProperty('WP_BASE_URL'); var auth = getAuthHeader(); var count = 0; while (files.hasNext()) { var file = files.next(); if (!file.getMimeType().startsWith('image/')) continue; var blob = file.getBlob(); var uploadUrl = baseUrl + '/wp-json/wp/v2/media'; var response = UrlFetchApp.fetch(uploadUrl, { method: 'post', headers: { 'Authorization': auth, 'Content-Disposition': 'attachment; filename="' + file.getName() + '"', 'Content-Type': file.getMimeType(), }, payload: blob.getBytes(), muteHttpExceptions: true }); var media = JSON.parse(response.getContentText()); // Attach to the inventory post if (media.id && count === 0) { // First image becomes featured image UrlFetchApp.fetch(baseUrl + '/wp-json/wp/v2/[client]_inventory/' + postId, { method: 'post', headers: { 'Authorization': auth, 'Content-Type': 'application/json' }, payload: JSON.stringify({ featured_media: media.id }), muteHttpExceptions: true }); } count++; } return count; }

On-serve watermarking — not on-upload

Watermarking on upload is the obvious approach, but it's the wrong one. When you watermark on upload, you're permanently altering the stored image. If you want to change the watermark (update the logo, move its position, adjust opacity), you have to re-process every image that was ever uploaded. That's a maintenance nightmare.

On-serve watermarking applies the watermark at request time, caches the result, and preserves the original. Changing the watermark means clearing the cache and regenerating — no stored images need to be touched.

On-serve watermark filter hook
add_filter( 'wp_get_attachment_image_src', '[client]_maybe_watermark', 10, 4 ); function [client]_maybe_watermark( $image, $attachment_id, $size, $icon ) { if ( ! $image ) return $image; if ( ! [client]_attachment_is_inventory_photo( $attachment_id ) ) return $image; if ( ! in_array( $size, [ '[client]-listing-hero', '[client]-gallery-thumb' ] ) ) { return $image; } $watermarked_url = [client]_get_or_create_watermarked( $image[0], $attachment_id ); if ( $watermarked_url ) { $image[0] = $watermarked_url; } return $image; } function [client]_get_or_create_watermarked( $src_url, $attachment_id ) { $cache_key = '[client]_wm_' . $attachment_id; $cached = get_transient( $cache_key ); if ( $cached ) return $cached; $result = [client]_apply_watermark_gd( $src_url ); if ( $result ) { set_transient( $cache_key, $result, WEEK_IN_SECONDS ); } return $result; }

Registered image sizes with hard crop

WordPress's default image sizes are soft-cropped (they scale to fit). For a listing gallery, you want hard crop — a fixed aspect ratio that produces consistent thumbnails. A grid of gallery thumbnails where each image has a different height looks broken.

Registered image sizes — plugin init
add_action( 'after_setup_theme', '[client]_register_image_sizes' ); function [client]_register_image_sizes() { add_image_size( '[client]-listing-hero', 1200, 675, true ); // 16:9 hard crop add_image_size( '[client]-gallery-thumb', 480, 360, true ); // 4:3 hard crop add_image_size( '[client]-grid-card', 600, 400, true ); // 3:2 hard crop }

The true third argument is the hard crop flag. Without it, WordPress scales the image to fit within the dimensions without cropping — you get inconsistent heights in a grid layout.

Cloudflare Polish for WebP conversion

WebP is 25–35% smaller than JPEG at equivalent visual quality. Converting every image to WebP at upload time is possible but adds significant processing overhead and storage. Cloudflare Polish does it for free at the CDN edge — images are stored as JPEG/PNG on the origin, and Cloudflare serves WebP automatically to browsers that support it.

Enable it in the Cloudflare dashboard: Speed → Optimization → Cloudflare Images → Polish. Set it to "Lossy" for maximum compression, or "Lossless" if you need to preserve pixel-perfect quality. The browser gets WebP; the origin keeps the original. Zero additional storage, zero processing on the server.


Why it matters

Photos are the product. A listing page with slow images is a listing page that doesn't convert. The pipeline automates the work that was previously 20–30 minutes per listing, protects against scrapers with on-serve watermarks that don't corrupt the originals, and uses the CDN to serve the smallest possible file to every visitor.

The Cloudflare CDN caching means photos are served from an edge node geographically close to the visitor, not from the origin server in Arizona. Image load time drops from 300–500ms (origin) to 20–50ms (edge). For a gallery with 40 images, that's the difference between the page feeling fast and feeling slow.


The Anchor build

The image pipeline processes hundreds of photos across the inventory. Every featured image was uploaded through the bulk script from the Drive folder structure. On-serve watermarking protects the branded images without modifying the stored originals. Cloudflare Polish serves WebP to browsers that support it, reducing gallery page weight by approximately 30%.

When a watermark update was needed — the dealership's logo changed — the watermark transient cache was cleared and the new watermark applied on the next request for each image. Zero stored images were touched.


Do this, not that

  • Watermark on serve, not on upload. On-serve watermarking with transient caching preserves originals and makes watermark changes a cache-clear, not a bulk reprocess.
  • Register image sizes with hard crop (true). Soft-crop produces inconsistent heights in grid layouts. Hard crop produces the consistent dimensions your grid expects.
  • Use Cloudflare Polish for WebP. Free at the CDN edge. No processing overhead on the origin, no additional storage, no format conversion workflow. Just turn it on.
  • Organize Drive folders by stock number from day one. Consistent folder naming makes the bulk upload script reliable — it knows exactly where to look for every unit's photos.
  • Use muteHttpExceptions: true on bulk upload requests. A single failed upload shouldn't terminate the entire batch. Log the failure and continue.
When you’re ready to build

The lessons are yours. When you want it built, we’re here.

Every lesson stays free — no account, no paywall, no email gate, ever. But if you’d rather have this system standing on your business than wire all 48 lessons yourself, leave your email. We’ll send you a direct line to a build — and you’ll be first to hear when we add new tools to the curriculum.

None of this gates a single lesson. The curriculum was free before you got here and it stays that way.

We’ll use your email to send you a fast-track to a GAP build and occasional notes on how GAP builds digital sales departments. Lessons stay 100% free — no email required to read any of them. We never share or sell your information. Unsubscribe any time. Privacy policy at gapindustriesllc.com/privacy.html.

Done learning how it’s built? We’ll build it.

You came here to understand the system, and now you do. If you’d rather have it standing on your business than spend the next three months wiring it yourself, GAP Concierge is the same architecture from these lessons — a white-label AI agent that knows your catalog and captures your leads — set up for you, from $97/mo.

See GAP Concierge →