29154bd651
Co-authored-by: Copilot <copilot@github.com>
217 lines
7.8 KiB
Python
217 lines
7.8 KiB
Python
"""
|
|
Canvas Image API - Hierarchical Image Fallback System
|
|
|
|
This module provides intelligent image serving with fallback logic:
|
|
1. Product-specific images
|
|
2. Subtype-level fallbacks
|
|
3. Type-level fallbacks
|
|
4. Global fallbacks
|
|
|
|
URL Pattern: /api/canvas/<product_code>/<layer>?color=<color>&material=<material>
|
|
"""
|
|
|
|
from flask import Blueprint, send_file, request, jsonify, current_app
|
|
import os
|
|
from pathlib import Path
|
|
import json
|
|
|
|
canvas_bp = Blueprint('canvas', __name__)
|
|
|
|
# Load product data to get type/subtype information
|
|
def get_product_data():
|
|
"""Load product data from JSON file"""
|
|
data_path = os.path.join(current_app.root_path, 'data', 'products.json')
|
|
try:
|
|
with open(data_path, 'r', encoding='utf-8') as f:
|
|
return json.load(f)
|
|
except Exception as e:
|
|
current_app.logger.error(f"Error loading product data: {e}")
|
|
return []
|
|
|
|
def get_product_info(product_code):
|
|
"""Get product type, subtype, and other metadata"""
|
|
products = get_product_data()
|
|
for product in products:
|
|
if product.get('productCode') == product_code or product.get('id') == product_code:
|
|
base_type = product.get('baseType', '').lower()
|
|
sub_type = None
|
|
|
|
# Extract subtype
|
|
subtype_obj = product.get('subType', {})
|
|
if isinstance(subtype_obj, dict):
|
|
if subtype_obj.get('door'):
|
|
sub_type = subtype_obj['door'].lower().replace(' ', '-')
|
|
elif subtype_obj.get('window'):
|
|
sub_type = subtype_obj['window'].lower().replace(' ', '-')
|
|
|
|
return {
|
|
'code': product_code,
|
|
'type': base_type,
|
|
'subtype': sub_type,
|
|
'colors': product.get('colors', []),
|
|
'materials': product.get('materials', [])
|
|
}
|
|
return None
|
|
|
|
def find_image_with_fallback(product_code, layer, color=None, material=None):
|
|
"""
|
|
Find image using hierarchical fallback system.
|
|
|
|
Priority order:
|
|
1. /images/<type>/<subtype>/<code>/<layer>-<color>.<ext>
|
|
2. /images/<type>/<subtype>/<code>/<layer>.<ext>
|
|
3. /images/<type>/<subtype>/layers/<layer>-<color>.<ext>
|
|
4. /images/<type>/<subtype>/layers/<layer>.<ext>
|
|
5. /images/<type>/layers/<layer>.<ext>
|
|
6. /images/layers/<layer>.<ext>
|
|
|
|
Returns: (file_path, mime_type) or (None, None)
|
|
"""
|
|
product_info = get_product_info(product_code)
|
|
if not product_info:
|
|
current_app.logger.warning(f"Product not found: {product_code}")
|
|
return None, None
|
|
|
|
images_dir = os.path.join(current_app.root_path, 'static', 'images')
|
|
base_type = product_info['type']
|
|
sub_type = product_info['subtype']
|
|
|
|
# Supported extensions
|
|
extensions = ['png', 'jpg', 'jpeg', 'webp']
|
|
|
|
# Build search paths in priority order
|
|
search_paths = []
|
|
|
|
# 1. Product-specific with color
|
|
if color and base_type and sub_type:
|
|
for ext in extensions:
|
|
search_paths.append(os.path.join(images_dir, base_type, sub_type, product_code, f"{layer}-{color.lower()}.{ext}"))
|
|
|
|
# 2. Product-specific without color
|
|
if base_type and sub_type:
|
|
for ext in extensions:
|
|
search_paths.append(os.path.join(images_dir, base_type, sub_type, product_code, f"{layer}.{ext}"))
|
|
|
|
# 3. Subtype fallback with color
|
|
if color and base_type and sub_type:
|
|
for ext in extensions:
|
|
search_paths.append(os.path.join(images_dir, base_type, sub_type, 'layers', f"{layer}-{color.lower()}.{ext}"))
|
|
|
|
# 4. Subtype fallback without color
|
|
if base_type and sub_type:
|
|
for ext in extensions:
|
|
search_paths.append(os.path.join(images_dir, base_type, sub_type, 'layers', f"{layer}.{ext}"))
|
|
|
|
# 5. Type fallback
|
|
if base_type:
|
|
for ext in extensions:
|
|
search_paths.append(os.path.join(images_dir, base_type, 'layers', f"{layer}.{ext}"))
|
|
|
|
# 6. Global fallback
|
|
for ext in extensions:
|
|
search_paths.append(os.path.join(images_dir, 'layers', f"{layer}.{ext}"))
|
|
|
|
# Search for first existing file
|
|
for path in search_paths:
|
|
if os.path.exists(path):
|
|
# Determine MIME type
|
|
ext = path.split('.')[-1].lower()
|
|
mime_types = {
|
|
'png': 'image/png',
|
|
'jpg': 'image/jpeg',
|
|
'jpeg': 'image/jpeg',
|
|
'webp': 'image/webp'
|
|
}
|
|
mime_type = mime_types.get(ext, 'image/png')
|
|
|
|
current_app.logger.info(f"Found image: {path}")
|
|
return path, mime_type
|
|
|
|
current_app.logger.warning(f"No image found for {product_code}/{layer} (color: {color})")
|
|
return None, None
|
|
|
|
@canvas_bp.route('/api/canvas/<product_code>/<layer>')
|
|
def get_layer_image(product_code, layer):
|
|
"""
|
|
Serve a specific layer image for a product with hierarchical fallback.
|
|
|
|
Query parameters:
|
|
- color: Optional color variant
|
|
- material: Optional material (for future use)
|
|
|
|
Returns: Image file or 404
|
|
"""
|
|
color = request.args.get('color')
|
|
material = request.args.get('material')
|
|
|
|
# Validate layer name (prevent directory traversal)
|
|
valid_layers = ['base', 'door', 'hardware', 'overlay', 'foreground']
|
|
if layer not in valid_layers:
|
|
return jsonify({'error': 'Invalid layer name'}), 400
|
|
|
|
# Find image with fallback
|
|
image_path, mime_type = find_image_with_fallback(product_code, layer, color, material)
|
|
|
|
if image_path:
|
|
return send_file(image_path, mimetype=mime_type)
|
|
else:
|
|
# Return placeholder or 404
|
|
return jsonify({
|
|
'error': 'Image not found',
|
|
'product': product_code,
|
|
'layer': layer,
|
|
'color': color
|
|
}), 404
|
|
|
|
@canvas_bp.route('/api/canvas/<product_code>/info')
|
|
def get_canvas_info(product_code):
|
|
"""
|
|
Get information about available layers for a product.
|
|
Useful for frontend to know which layers exist.
|
|
"""
|
|
product_info = get_product_info(product_code)
|
|
|
|
if not product_info:
|
|
return jsonify({'error': 'Product not found'}), 404
|
|
|
|
# Check which layers are available
|
|
available_layers = {}
|
|
layers_to_check = ['base', 'door', 'hardware', 'overlay', 'foreground']
|
|
|
|
for layer in layers_to_check:
|
|
# Check if layer exists (with or without color)
|
|
path, _ = find_image_with_fallback(product_code, layer)
|
|
if path:
|
|
available_layers[layer] = True
|
|
|
|
# For door layer, check which colors are available
|
|
if layer == 'door' and product_info['colors']:
|
|
available_colors = []
|
|
for color in product_info['colors']:
|
|
color_path, _ = find_image_with_fallback(product_code, layer, color)
|
|
if color_path:
|
|
available_colors.append(color)
|
|
available_layers['door_colors'] = available_colors
|
|
|
|
return jsonify({
|
|
'product': product_code,
|
|
'type': product_info['type'],
|
|
'subtype': product_info['subtype'],
|
|
'availableLayers': available_layers,
|
|
'colors': product_info['colors'],
|
|
'materials': product_info['materials']
|
|
})
|
|
|
|
@canvas_bp.route('/api/canvas/test')
|
|
def test_canvas():
|
|
"""Test endpoint to verify canvas API is working"""
|
|
return jsonify({
|
|
'status': 'ok',
|
|
'message': 'Canvas API is running',
|
|
'endpoints': {
|
|
'get_layer': '/api/canvas/<product_code>/<layer>?color=<color>',
|
|
'get_info': '/api/canvas/<product_code>/info',
|
|
'test': '/api/canvas/test'
|
|
}
|
|
})
|