Add WebP thumbnail endpoint for MLS property images

- Create MLS_Image_Endpoint class with on-demand thumbnail generation
- Use ImageMagick to convert images to WebP format
- Thumbnail sizes: 800px (thumb) and 1800px (full), maintain aspect ratio
- Only downsize images, never upsize
- Cache thumbnails in wp-content/uploads/mls-thumbnails/
- Add mls_get_image_url() helper function (1-based index)
- Update property cards to display thumbnail as background-cover image
- Long cache headers (1 year) with ETag support

URL format: /mls-image/{listing_key}/{index}/{size}/

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Hanson.xyz Dev
2025-12-15 23:28:59 -06:00
parent 198c9b9091
commit 4db53b607c
5 changed files with 475 additions and 3 deletions
@@ -0,0 +1,430 @@
<?php
/**
* MLS Image Endpoint
*
* Serves WebP thumbnails for MLS property images with on-demand conversion.
* Uses ImageMagick to convert and resize images.
*
* URL format: /mls-image/{listing_key}/{index}/{size}/
* - listing_key: MLS listing key
* - index: Image index (0-based)
* - size: 'thumb' (800px) or 'full' (1800px)
*/
if (!defined('ABSPATH')) {
exit;
}
class MLS_Image_Endpoint {
/**
* Size configurations
*/
const SIZE_THUMB = 800;
const SIZE_FULL = 1800;
/**
* WebP quality (0-100)
*/
const WEBP_QUALITY = 82;
/**
* Cache subdirectory for thumbnails
*/
const CACHE_SUBDIR = 'mls-thumbnails';
/**
* Media handler instance
*/
private $media_handler;
/**
* Logger instance
*/
private $logger;
/**
* Constructor
*/
public function __construct(MLS_Media_Handler $media_handler, MLS_Logger $logger) {
$this->media_handler = $media_handler;
$this->logger = $logger;
}
/**
* Initialize hooks
*/
public function init() {
add_action('init', array($this, 'add_rewrite_rules'));
add_filter('query_vars', array($this, 'add_query_vars'));
add_action('template_redirect', array($this, 'handle_request'));
}
/**
* Add rewrite rules for image endpoint
*/
public function add_rewrite_rules() {
add_rewrite_rule(
'^mls-image/([^/]+)/([0-9]+)/(thumb|full)/?$',
'index.php?mls_image=1&mls_listing_key=$matches[1]&mls_image_index=$matches[2]&mls_image_size=$matches[3]',
'top'
);
}
/**
* Add query vars
*/
public function add_query_vars($vars) {
$vars[] = 'mls_image';
$vars[] = 'mls_listing_key';
$vars[] = 'mls_image_index';
$vars[] = 'mls_image_size';
return $vars;
}
/**
* Handle image request
*/
public function handle_request() {
if (!get_query_var('mls_image')) {
return;
}
$listing_key = sanitize_text_field(get_query_var('mls_listing_key'));
$index = absint(get_query_var('mls_image_index'));
$size = sanitize_text_field(get_query_var('mls_image_size'));
if (empty($listing_key) || !in_array($size, array('thumb', 'full'), true)) {
$this->logger->error('MLS Image: Invalid params', array(
'listing_key' => $listing_key,
'index' => $index,
'size' => $size,
));
$this->send_404();
return;
}
$max_dimension = ($size === 'thumb') ? self::SIZE_THUMB : self::SIZE_FULL;
// Try to serve from cache first
$cached_path = $this->get_cached_path($listing_key, $index, $size);
if (file_exists($cached_path)) {
$this->serve_image($cached_path);
return;
}
// Get the source image
$source_path = $this->get_source_image($listing_key, $index);
if (!$source_path) {
$this->logger->error('MLS Image: Source not found', array(
'listing_key' => $listing_key,
'index' => $index,
));
$this->send_404();
return;
}
// Generate thumbnail
$result = $this->generate_thumbnail($source_path, $cached_path, $max_dimension);
if (!$result) {
// Fall back to serving original if conversion fails
$this->serve_image($source_path, $this->get_mime_type($source_path));
return;
}
$this->serve_image($cached_path);
}
/**
* Get source image path, fetching from MLS if needed
*/
private function get_source_image($listing_key, $index) {
global $wpdb;
$plugin = mls_plugin();
$db = $plugin->get_db();
// Get media record for this index
$media = $wpdb->get_row($wpdb->prepare(
"SELECT * FROM {$db->media_table()}
WHERE listing_key = %s AND media_order = %d
LIMIT 1",
$listing_key,
$index
));
if (!$media) {
return null;
}
// Check if already cached locally
if ($media->local_path) {
$full_path = $this->media_handler->get_upload_dir() . '/' . $media->local_path;
if (file_exists($full_path)) {
return $full_path;
}
}
// Fetch from MLS on demand
$url = $this->media_handler->get_image_url($media, true);
if (!$url) {
return null;
}
// Re-fetch the record to get updated local_path
$media = $wpdb->get_row($wpdb->prepare(
"SELECT * FROM {$db->media_table()}
WHERE listing_key = %s AND media_order = %d
LIMIT 1",
$listing_key,
$index
));
if ($media && $media->local_path) {
$full_path = $this->media_handler->get_upload_dir() . '/' . $media->local_path;
if (file_exists($full_path)) {
return $full_path;
}
}
return null;
}
/**
* Get cached thumbnail path
*/
private function get_cached_path($listing_key, $index, $size) {
$cache_dir = $this->get_cache_dir($listing_key);
return $cache_dir . '/' . $index . '-' . $size . '.webp';
}
/**
* Get cache directory for a listing
*/
private function get_cache_dir($listing_key) {
$upload_dir = wp_upload_dir();
$prefix = substr($listing_key, 0, 2);
return $upload_dir['basedir'] . '/' . self::CACHE_SUBDIR . '/' . $prefix . '/' . $listing_key;
}
/**
* Generate WebP thumbnail using ImageMagick
*/
private function generate_thumbnail($source_path, $dest_path, $max_dimension) {
// Create destination directory
$dest_dir = dirname($dest_path);
if (!file_exists($dest_dir)) {
wp_mkdir_p($dest_dir);
}
// Check for ImageMagick
$convert_path = $this->get_imagemagick_path();
if (!$convert_path) {
$this->logger->error('ImageMagick not found');
return false;
}
// Get source dimensions
$size_info = @getimagesize($source_path);
if (!$size_info) {
$this->logger->error('Failed to get image dimensions', array('path' => $source_path));
return false;
}
$source_width = $size_info[0];
$source_height = $size_info[1];
// Only downsize, never upsize
if ($source_width <= $max_dimension && $source_height <= $max_dimension) {
// Just convert to WebP without resizing
$resize_arg = '';
} else {
// Resize maintaining aspect ratio, > means only shrink
$resize_arg = '-resize ' . escapeshellarg("{$max_dimension}x{$max_dimension}>");
}
// Build ImageMagick command
// -strip: Remove metadata
// -quality: WebP quality
// -define webp:method=6: Best compression method
$command = sprintf(
'%s %s -strip %s -quality %d -define webp:method=6 %s 2>&1',
escapeshellcmd($convert_path),
escapeshellarg($source_path),
$resize_arg,
self::WEBP_QUALITY,
escapeshellarg($dest_path)
);
$output = array();
$return_var = 0;
exec($command, $output, $return_var);
if ($return_var !== 0) {
$this->logger->error('ImageMagick conversion failed', array(
'command' => $command,
'output' => implode("\n", $output),
'return' => $return_var,
));
return false;
}
return file_exists($dest_path);
}
/**
* Get ImageMagick convert path
*/
private function get_imagemagick_path() {
// Common paths
$paths = array(
'/usr/bin/convert',
'/usr/local/bin/convert',
'/opt/local/bin/convert',
'convert', // System PATH
);
foreach ($paths as $path) {
if ($path === 'convert') {
// Check if available in PATH
$output = array();
$return_var = 0;
exec('which convert 2>/dev/null', $output, $return_var);
if ($return_var === 0 && !empty($output[0])) {
return trim($output[0]);
}
} elseif (file_exists($path) && is_executable($path)) {
return $path;
}
}
return null;
}
/**
* Serve image file
*/
private function serve_image($path, $mime_type = 'image/webp') {
if (!file_exists($path)) {
$this->send_404();
return;
}
$file_size = filesize($path);
$last_modified = filemtime($path);
$etag = md5($path . $last_modified);
// Check for conditional request
$if_modified = isset($_SERVER['HTTP_IF_MODIFIED_SINCE'])
? strtotime($_SERVER['HTTP_IF_MODIFIED_SINCE'])
: false;
$if_none_match = isset($_SERVER['HTTP_IF_NONE_MATCH'])
? trim($_SERVER['HTTP_IF_NONE_MATCH'], '"')
: false;
if (($if_modified && $if_modified >= $last_modified) ||
($if_none_match && $if_none_match === $etag)) {
header('HTTP/1.1 304 Not Modified');
exit;
}
// Send headers
header('Content-Type: ' . $mime_type);
header('Content-Length: ' . $file_size);
header('Last-Modified: ' . gmdate('D, d M Y H:i:s', $last_modified) . ' GMT');
header('ETag: "' . $etag . '"');
header('Cache-Control: public, max-age=31536000'); // 1 year
header('Expires: ' . gmdate('D, d M Y H:i:s', time() + 31536000) . ' GMT');
// Stream file
readfile($path);
exit;
}
/**
* Get MIME type for file
*/
private function get_mime_type($path) {
$ext = strtolower(pathinfo($path, PATHINFO_EXTENSION));
$types = array(
'jpg' => 'image/jpeg',
'jpeg' => 'image/jpeg',
'png' => 'image/png',
'gif' => 'image/gif',
'webp' => 'image/webp',
);
return isset($types[$ext]) ? $types[$ext] : 'application/octet-stream';
}
/**
* Send 404 response
*/
private function send_404() {
status_header(404);
nocache_headers();
exit;
}
/**
* Get URL for an MLS image
*
* @param string $listing_key Listing key
* @param int $index Image index (1-based, matches media_order from MLS)
* @param string $size 'thumb' or 'full'
* @return string Image URL
*/
public static function get_url($listing_key, $index = 1, $size = 'thumb') {
return home_url("/mls-image/{$listing_key}/{$index}/{$size}/");
}
/**
* Clear thumbnail cache for a listing
*
* @param string $listing_key Listing key
*/
public function clear_cache($listing_key) {
$cache_dir = $this->get_cache_dir($listing_key);
if (is_dir($cache_dir)) {
$files = glob($cache_dir . '/*');
foreach ($files as $file) {
if (is_file($file)) {
unlink($file);
}
}
rmdir($cache_dir);
}
}
/**
* Get cache statistics
*
* @return array Cache stats
*/
public function get_cache_stats() {
$upload_dir = wp_upload_dir();
$cache_base = $upload_dir['basedir'] . '/' . self::CACHE_SUBDIR;
$stats = array(
'total_files' => 0,
'total_size' => 0,
);
if (!is_dir($cache_base)) {
return $stats;
}
$iterator = new RecursiveIteratorIterator(
new RecursiveDirectoryIterator($cache_base, RecursiveDirectoryIterator::SKIP_DOTS),
RecursiveIteratorIterator::SELF_FIRST
);
foreach ($iterator as $file) {
if ($file->isFile() && $file->getExtension() === 'webp') {
$stats['total_files']++;
$stats['total_size'] += $file->getSize();
}
}
return $stats;
}
}
@@ -52,6 +52,7 @@ final class MLS_Plugin {
private $api_client;
private $sync_engine;
private $media_handler;
private $image_endpoint;
private $query;
/**
@@ -84,6 +85,7 @@ final class MLS_Plugin {
require_once MLS_PLUGIN_DIR . 'includes/class-mls-api-client.php';
require_once MLS_PLUGIN_DIR . 'includes/class-mls-sync-engine.php';
require_once MLS_PLUGIN_DIR . 'includes/class-mls-media-handler.php';
require_once MLS_PLUGIN_DIR . 'includes/class-mls-image-endpoint.php';
require_once MLS_PLUGIN_DIR . 'includes/class-mls-query.php';
// Activation/Deactivation
@@ -126,6 +128,8 @@ final class MLS_Plugin {
$this->rate_limiter = new MLS_Rate_Limiter($this->db);
$this->api_client = new MLS_API_Client($this->options, $this->rate_limiter, $this->logger);
$this->media_handler = new MLS_Media_Handler($this->db, $this->logger);
$this->image_endpoint = new MLS_Image_Endpoint($this->media_handler, $this->logger);
$this->image_endpoint->init();
$this->sync_engine = new MLS_Sync_Engine(
$this->db,
$this->api_client,
@@ -206,6 +210,13 @@ final class MLS_Plugin {
return $this->media_handler;
}
/**
* Get Image Endpoint instance
*/
public function get_image_endpoint() {
return $this->image_endpoint;
}
/**
* Get Query instance
*/
@@ -359,3 +370,21 @@ function mls_get_cache_stats() {
}
return $plugin->get_media_handler()->get_cache_stats();
}
/**
* Get WebP thumbnail URL for a listing image
*
* Returns a URL to the WebP thumbnail endpoint which will:
* - Fetch the image from MLS Grid if not cached
* - Convert to WebP format using ImageMagick
* - Resize to requested dimension (800px thumb or 1800px full)
* - Cache the result for future requests
*
* @param string $listing_key The listing key
* @param int $index Image index (1-based, default: 1 for primary image)
* @param string $size 'thumb' (800px) or 'full' (1800px), default: 'thumb'
* @return string Image URL
*/
function mls_get_image_url($listing_key, $index = 1, $size = 'thumb') {
return MLS_Image_Endpoint::get_url($listing_key, $index, $size);
}
File diff suppressed because one or more lines are too long
@@ -59,18 +59,23 @@ if ($status === 'Pending') {
} elseif ($status === 'Closed' || $status === 'Sold') {
$status_class = 'badge-sold';
}
// Get thumbnail image URL (index 1 = first image)
$image_url = function_exists('mls_get_image_url') ? mls_get_image_url($listing_key, 1, 'thumb') : '';
$has_image = !empty($image_url);
?>
<article id="property-<?php echo esc_attr($listing_key); ?>" data-property-id="<?php echo esc_attr($listing_key); ?>" class="property-card card mls-property">
<a href="<?php echo esc_url($property_url); ?>" class="property-card-link-overlay" aria-hidden="true" tabindex="-1"></a>
<div class="property-card-image">
<!-- Photo placeholder - will be implemented later -->
<div class="property-card-image<?php echo $has_image ? ' has-image' : ''; ?>"<?php if ($has_image) : ?> style="background-image: url('<?php echo esc_url($image_url); ?>');"<?php endif; ?>>
<?php if (!$has_image) : ?>
<div class="property-card-placeholder">
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" aria-hidden="true">
<path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/>
<polyline points="9 22 9 12 15 12 15 22"/>
</svg>
</div>
<?php endif; ?>
<?php if ($status) : ?>
<span class="property-card-badge badge <?php echo esc_attr($status_class); ?>">
@@ -40,6 +40,14 @@
aspect-ratio: 16 / 10;
overflow: hidden;
background-color: var(--color-bg-dark);
background-size: cover;
background-position: center;
background-repeat: no-repeat;
&.has-image {
// Loading state - shows subtle animation while image loads
background-color: var(--color-bg-card);
}
img {
width: 100%;