Photography Project

October 01, 2026 12:16am

An AI-assisted photo gallery: Gemini-generated titles and tags, EXIF data, server-side thumbnails and a failure-only model fallback.

Overview

The Photography module is a searchable gallery: a paginated grid at /photos and a full-width page per photo, with its camera data and tags. What makes it different from the rest of the site is what happens at upload time. Each photo is sent to Google Gemini, which suggests a title, a short description and a set of tags, while PHP reads the EXIF data and generates the thumbnail on the server.

The goal was simple: uploading a photo should take one click, and still produce a page that is described, tagged and searchable.

Technical Stack

Area Technologies
Backend PHP 8.3, OOP (PhotoRepo / PhotoRender)
Database MySQL 8.0 via PDO, JSON column for EXIF
Image processing GD (thumbnails), PHP's EXIF extension
AI Google Gemini API over plain cURL, with a primary and a secondary model

Architecture & Design

The module follows the same Repo/Render pattern as the rest of the site, and was modelled directly on the Downloads module (a parent entity with generated images and an admin CRUD).

  • new_horta_photos: the photo itself, with the original image, the generated thumbnail, title, description, an ai_generated flag and the full EXIF data in a single JSON column. Normalizing dozens of optional EXIF fields into columns would have added a lot of schema for data that is only ever displayed.
  • taken_at: the one EXIF value that is searched and sorted on is promoted to its own indexed DATETIME column, so date filters use an index instead of a JSON path expression.
  • new_horta_photo_tags + new_horta_photos_tag: normalized tags and a bridge table. Tags are free-form (they come from the AI), so they're resolved with a find-or-create lookup.

The upload flow

  1. The file is stored, the EXIF data is extracted, and a 480px thumbnail is generated with GD, always re-encoded to JPEG.
  2. Gemini receives the image and must answer with a JSON object: a 3-6 word title, one or two sentences of description, and 5-10 lowercase tags.
  3. Title and description are filled in only if left blank, so anything typed by hand wins. Manual tags are merged with the AI's suggestions, not replaced.

Key Features

  • Search and filters: free text over title, description and tags, a tag filter, and a date range on when the photo was taken, all shareable as a link.
  • Clickable tag chips on the grid and detail pages, each linking to the grid filtered by that tag.
  • Readable camera data: the detail page turns raw EXIF (rationals like 1/250, fields that differ between camera makers) into camera, lens, aperture, shutter speed, ISO and focal length.
  • Bulk upload: up to 10 photos per submission, each one fully described and tagged by the AI, with a created/failed summary at the end.
  • Tag management: rename or delete tags in the admin.
  • Lightbox for the full-size image.

Challenges & Solutions

Challenge Solution
External AI can fail The client never throws. If the primary model fails (network error, non-2xx response, or a reply that isn't valid JSON), it retries once on a secondary model. If that fails too, the upload still succeeds: the title falls back to one derived from the filename, and tags are left for manual entry.
No fallback by default The secondary model is only ever used after a failure, never raced against the primary or used as a default. Costs and results stay predictable.
Rate limits AI runs synchronously inside the upload request, which is fine for a single admin. The bulk upload is capped at 10 files so it stays within Gemini's limits and the request doesn't time out.
Tag links without N+1 queries The grid query returns each photo's tags as id:::tag pairs joined by ||| in a single GROUP_CONCAT. The renderer splits them back, so tag chips link by id without one query per photo.
Server-side image resizing No other module resized images before. GD scales the longest side to 480px, never upscales, and always writes JPEG, so thumbnails are small whatever the source format (JPEG, PNG or WebP).

Security & Best Practices

  1. Least privilege: the gallery reads through the read-only database user; uploads and edits use the admin connection behind the login.
  2. Prepared statements for every write, including tag creation and the photo-tag bridge.
  3. Escaped output: AI-generated text is treated exactly like user input and escaped before rendering.
  4. Secrets stay server-side: the API key and model names live in the untracked configuration file, never in the repository.

Results & Learnings

The module showed that AI works best as a helpful default, not a hard dependency. Keeping the fallback strictly failure-driven, and letting anything typed by hand win, means the AI saves time without ever being in the way.

It also left a clear learning for later modules: the grid's search filters are built by escaping values into the SQL string, which is safe here but not the best pattern. The Formula Duude and iRacing modules that came after it moved to fully bound parameters, and Photos will follow when it's next revised.

Gemini works like a friendly assistant at the photo lab: it scribbles a caption and a few labels on the back of each print. If the assistant is out that day, the print still goes into the album, just without the scribbles.

Code Snippets

1. Failure-driven model fallback

public static function analyzeImage(string $filePath): array
{
    $result = self::callModel(PRIMARY_GEMINI_MODEL, $filePath);
    if ($result['ok']) {
        return $result;
    }
    error_log('Gemini primary model (' . PRIMARY_GEMINI_MODEL . ') failed: ' . $result['error']);
    $result = self::callModel(SECONDARY_GEMINI_MODEL, $filePath);
    if ($result['ok']) {
        return $result;
    }
    error_log('Gemini secondary model (' . SECONDARY_GEMINI_MODEL . ') failed: ' . $result['error']);
    return ['ok' => false, 'title' => '', 'description' => '', 'tags' => [], 'error' => $result['error']];
}

2. Thumbnail generation with GD

$srcWidth = imagesx($source);
$srcHeight = imagesy($source);
$ratio = min($maxDim / $srcWidth, $maxDim / $srcHeight, 1);
$dstWidth = max(1, (int)round($srcWidth * $ratio));
$dstHeight = max(1, (int)round($srcHeight * $ratio));

$dest = imagecreatetruecolor($dstWidth, $dstHeight);
imagecopyresampled($dest, $source, 0, 0, 0, 0, $dstWidth, $dstHeight, $srcWidth, $srcHeight);
$ok = imagejpeg($dest, $destPath, 82);

3. Tag pairs from a single GROUP_CONCAT

private function parseTagPairs(?string $raw): array {
    if (!$raw) {
        return [];
    }
    $pairs = [];
    foreach (explode('|||', $raw) as $item) {
        [$id, $name] = array_pad(explode(':::', $item, 2), 2, null);
        if ($id !== null && $name !== null) {
            $pairs[] = ['id' => (int)$id, 'tag' => $name];
        }
    }
    return $pairs;
}

go to photos