fix: complete image pipeline - rotation, URL, preview
- Add rotation_degrees parameter to ImageProcessor.process_photo() - Pass rotation through _auto_save_photo_from_extraction() to processor - Allow no-crop fallback when crop_bounds is None - Add buildPhotoUrl() helper to resolve backend URLs correctly - Update frontend components to use backend URL for image sources - Replace Use/Skip Photo buttons with checkbox in AI extraction UI - Add images/ to .gitignore to prevent accidental commits Addresses: rotation never applied, image 404s (relative to Next.js not backend), preview blank in edit form
This commit is contained in:
@@ -95,7 +95,9 @@
|
||||
"Bash(npx vitest *)",
|
||||
"Bash(sed -i 's/jest\\\\.fn\\(\\)/vi.fn\\(\\)/g' tests/hooks/useAIExtraction.test.ts)",
|
||||
"Bash(sed -i 's/as jest\\\\.Mock/as any/g' tests/hooks/useAIExtraction.test.ts)",
|
||||
"Bash(grep -E \"\\\\.tsx?$\")"
|
||||
"Bash(grep -E \"\\\\.tsx?$\")",
|
||||
"Bash(sqlite3 *)",
|
||||
"Skill(gsd-resume-work)"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -148,6 +148,7 @@ backend/.env.test
|
||||
# Test images uploaded during development
|
||||
_images.tests/
|
||||
_images/
|
||||
images/
|
||||
|
||||
# ── Local AI Tooling & Persistent Paths ──────────────────────
|
||||
.tool_paths
|
||||
|
||||
@@ -503,54 +503,48 @@ def _auto_save_photo_from_extraction(
|
||||
"reason": "Empty image bytes"
|
||||
}
|
||||
|
||||
# Graceful skip if crop_bounds is None
|
||||
if crop_bounds is None:
|
||||
log.info(f"Auto-save photo for item {item_id}: crop_bounds is None, skipping")
|
||||
return {
|
||||
"status": "skipped",
|
||||
"reason": "crop_bounds is None"
|
||||
}
|
||||
# Validate crop_bounds (if provided)
|
||||
crop_bounds_validated = None
|
||||
if crop_bounds is not None:
|
||||
if not isinstance(crop_bounds, dict):
|
||||
log.warning(f"Auto-save photo for item {item_id}: crop_bounds is not a dict, skipping")
|
||||
return {
|
||||
"status": "skipped",
|
||||
"reason": "crop_bounds must be a dict"
|
||||
}
|
||||
|
||||
# Validate crop_bounds
|
||||
if not isinstance(crop_bounds, dict):
|
||||
log.warning(f"Auto-save photo for item {item_id}: crop_bounds is not a dict, skipping")
|
||||
return {
|
||||
"status": "skipped",
|
||||
"reason": "crop_bounds must be a dict"
|
||||
}
|
||||
# Check for required keys
|
||||
required_keys = {'x', 'y', 'width', 'height'}
|
||||
if not required_keys.issubset(crop_bounds.keys()):
|
||||
missing = required_keys - set(crop_bounds.keys())
|
||||
log.warning(f"Auto-save photo for item {item_id}: Missing crop_bounds keys: {missing}")
|
||||
return {
|
||||
"status": "skipped",
|
||||
"reason": f"Missing crop_bounds keys: {missing}"
|
||||
}
|
||||
|
||||
# Check for required keys
|
||||
required_keys = {'x', 'y', 'width', 'height'}
|
||||
if not required_keys.issubset(crop_bounds.keys()):
|
||||
missing = required_keys - set(crop_bounds.keys())
|
||||
log.warning(f"Auto-save photo for item {item_id}: Missing crop_bounds keys: {missing}")
|
||||
return {
|
||||
"status": "skipped",
|
||||
"reason": f"Missing crop_bounds keys: {missing}"
|
||||
}
|
||||
# Validate all values are integers >= 0
|
||||
try:
|
||||
crop_bounds_validated = {}
|
||||
for key in required_keys:
|
||||
val = crop_bounds[key]
|
||||
# Convert to int if it's numeric
|
||||
if isinstance(val, (int, float)):
|
||||
int_val = int(val)
|
||||
else:
|
||||
raise ValueError(f"Non-numeric value for {key}: {val}")
|
||||
|
||||
# Validate all values are integers >= 0
|
||||
try:
|
||||
crop_bounds_validated = {}
|
||||
for key in required_keys:
|
||||
val = crop_bounds[key]
|
||||
# Convert to int if it's numeric
|
||||
if isinstance(val, (int, float)):
|
||||
int_val = int(val)
|
||||
else:
|
||||
raise ValueError(f"Non-numeric value for {key}: {val}")
|
||||
if int_val < 0:
|
||||
raise ValueError(f"Negative value for {key}: {int_val}")
|
||||
|
||||
if int_val < 0:
|
||||
raise ValueError(f"Negative value for {key}: {int_val}")
|
||||
crop_bounds_validated[key] = int_val
|
||||
|
||||
crop_bounds_validated[key] = int_val
|
||||
|
||||
except (ValueError, TypeError) as e:
|
||||
log.warning(f"Auto-save photo for item {item_id}: Invalid crop_bounds: {str(e)}")
|
||||
return {
|
||||
"status": "skipped",
|
||||
"reason": f"Invalid crop_bounds: {str(e)}"
|
||||
}
|
||||
except (ValueError, TypeError) as e:
|
||||
log.warning(f"Auto-save photo for item {item_id}: Invalid crop_bounds: {str(e)}")
|
||||
return {
|
||||
"status": "skipped",
|
||||
"reason": f"Invalid crop_bounds: {str(e)}"
|
||||
}
|
||||
|
||||
# Validate rotation_degrees (optional but if provided, must be valid)
|
||||
if rotation_degrees is not None:
|
||||
@@ -573,7 +567,7 @@ def _auto_save_photo_from_extraction(
|
||||
try:
|
||||
# Process image (crop + rotation + compression + thumbnail)
|
||||
processor = ImageProcessor()
|
||||
process_result = processor.process_photo(image_bytes, crop_bounds_validated)
|
||||
process_result = processor.process_photo(image_bytes, crop_bounds_validated, rotation_degrees=rotation_degrees or 0)
|
||||
|
||||
if process_result.get('status') != 'success':
|
||||
error_msg = process_result.get('error', 'Unknown error')
|
||||
|
||||
@@ -43,7 +43,7 @@ class ImageProcessor:
|
||||
self.logger = logger
|
||||
|
||||
def process_photo(
|
||||
self, file_bytes: bytes, crop_bounds: Optional[Dict] = None
|
||||
self, file_bytes: bytes, crop_bounds: Optional[Dict] = None, rotation_degrees: float = 0
|
||||
) -> Dict:
|
||||
"""
|
||||
Process a photo with EXIF rotation, smart cropping, and compression.
|
||||
@@ -51,6 +51,7 @@ class ImageProcessor:
|
||||
Args:
|
||||
file_bytes: Raw image file bytes
|
||||
crop_bounds: Optional manual crop bounds {x, y, width, height}
|
||||
rotation_degrees: Optional manual rotation in degrees (applied after crop)
|
||||
|
||||
Returns:
|
||||
{
|
||||
@@ -136,6 +137,11 @@ class ImageProcessor:
|
||||
)
|
||||
crop_method = 'pillow'
|
||||
|
||||
# Apply manual rotation if provided
|
||||
if abs(rotation_degrees) > 0.5:
|
||||
cropped_image = cropped_image.rotate(-rotation_degrees, expand=True)
|
||||
self.logger.info(f"Applied manual rotation: {rotation_degrees}°")
|
||||
|
||||
# Resize and compress
|
||||
compressed_bytes = self._resize_and_compress(cropped_image)
|
||||
|
||||
|
||||
@@ -18,10 +18,10 @@
|
||||
- **CABLE/WIRE LENGTH**: Meters only. Examples: "5m", "10m", "50m"
|
||||
- **RAM DIMM**: Capacity in GB. Examples: "128GB", "32GB", "8GB"
|
||||
|
||||
- `<part_number>`: Part number ONLY if visible. If the item has an identifiable Part Number, search web for what item this is and extract needed informations or compare with what was already identified, and correct all fields. **Omit serial numbers.**
|
||||
- `<type>`: Asset class. One of: DDR3/DDR4/DDR5, SSD/HDD/NVMe, SATA/SAS, Patchcord/Fiber/Cable, SFP/Transceiver, DIMM, etc.
|
||||
- `<vendor>`: Manufacturer (HP, HPE, Dell, Samsung, Cisco, Hynix, Intel, Broadcom)
|
||||
- `<connector>`: Physical interface (RJ45, LC-LC, MPO, U.3, SATA, SAS, ST, SC). Omit if N/A.
|
||||
- `<part_number>`: Part number ONLY if visible. **Omit serial numbers.**
|
||||
|
||||
**Item Examples (WITH HUMAN-READABLE SIZES):**
|
||||
- `1.6TB NVMe HPE U.3 P66093-002` (not 1600GB)
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { db, Item } from '@/lib/db';
|
||||
import { inventoryApi } from '@/lib/api';
|
||||
import { inventoryApi, getBackendUrl } from '@/lib/api';
|
||||
import PageShell from '@/components/PageShell';
|
||||
import Scanner from '@/components/Scanner';
|
||||
import StatCard from '@/components/StatCard';
|
||||
@@ -41,6 +41,7 @@ export default function InventoryPage() {
|
||||
const [inventory, setInventory] = useState<Item[]>([]);
|
||||
const [stats, setStats] = useState<any>(null);
|
||||
const [currentUser, setCurrentUser] = useState<any | null>(null);
|
||||
const [backendUrl, setBackendUrl] = useState<string>('');
|
||||
|
||||
const {
|
||||
searchQuery,
|
||||
@@ -86,6 +87,7 @@ export default function InventoryPage() {
|
||||
setCurrentUser(JSON.parse(savedUser));
|
||||
}
|
||||
|
||||
getBackendUrl().then(setBackendUrl);
|
||||
loadData();
|
||||
}, []);
|
||||
|
||||
@@ -311,6 +313,7 @@ export default function InventoryPage() {
|
||||
}
|
||||
}}
|
||||
categoriesList={categoriesList}
|
||||
backendUrl={backendUrl}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -237,23 +237,15 @@ export default function AIOnboarding({ onCancel, onComplete, categories, invento
|
||||
</div>
|
||||
<div className="p-3 space-y-2">
|
||||
<p className="text-xs text-muted font-normal">Extracted Photo</p>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => updateEditingItem({ _skipPhoto: false })}
|
||||
className="flex-1 px-3 py-2 bg-green-500/20 border border-green-500/50 text-green-400 rounded-lg hover:bg-green-500/30 transition-colors font-normal text-xs"
|
||||
>
|
||||
Use Photo
|
||||
</button>
|
||||
<button
|
||||
onClick={() => updateEditingItem({ _skipPhoto: true })}
|
||||
className="flex-1 px-3 py-2 bg-rose-500/20 border border-rose-500/50 text-rose-400 rounded-lg hover:bg-rose-500/30 transition-colors font-normal text-xs"
|
||||
>
|
||||
Skip Photo
|
||||
</button>
|
||||
</div>
|
||||
{extractedItems[editingIndex]._skipPhoto && (
|
||||
<p className="text-xs text-rose-400/80">Photo will not be saved</p>
|
||||
)}
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={!extractedItems[editingIndex]._skipPhoto}
|
||||
onChange={(e) => updateEditingItem({ _skipPhoto: !e.target.checked })}
|
||||
className="w-4 h-4 accent-primary cursor-pointer"
|
||||
/>
|
||||
<span className="text-xs text-secondary font-normal">Save this photo with the item</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { useState } from 'react';
|
||||
import { Item } from '@/lib/db';
|
||||
import { buildPhotoUrl } from '@/lib/api';
|
||||
import { ChevronRight, ChevronDown, Layers, Package } from 'lucide-react';
|
||||
import { clsx, type ClassValue } from 'clsx';
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
@@ -20,6 +21,7 @@ interface InventoryTableProps {
|
||||
onItemClick: (item: Item) => void;
|
||||
onEditCategory?: (category: string) => void;
|
||||
categoriesList?: any[];
|
||||
backendUrl?: string;
|
||||
}
|
||||
|
||||
export default function InventoryTable({
|
||||
@@ -29,7 +31,8 @@ export default function InventoryTable({
|
||||
onExpandCategory,
|
||||
onItemClick,
|
||||
onEditCategory,
|
||||
categoriesList = []
|
||||
categoriesList = [],
|
||||
backendUrl = ''
|
||||
}: InventoryTableProps) {
|
||||
const [selectedItemDetail, setSelectedItemDetail] = useState<Item | null>(null);
|
||||
const [refreshTrigger, setRefreshTrigger] = useState(0);
|
||||
@@ -115,7 +118,7 @@ export default function InventoryTable({
|
||||
className="w-12 h-12 rounded-xl shrink-0 border-2 border-slate-300 overflow-hidden cursor-pointer hover:border-primary transition-colors active:scale-95"
|
||||
>
|
||||
<img
|
||||
src={item.photo_path || item.image_url || ''}
|
||||
src={buildPhotoUrl(backendUrl, item.photo_path || item.image_url || '')}
|
||||
alt={item.name}
|
||||
className="w-full h-full object-cover"
|
||||
loading="lazy"
|
||||
@@ -160,6 +163,7 @@ export default function InventoryTable({
|
||||
item={selectedItemDetail}
|
||||
onClose={handleCloseDetail}
|
||||
onItemRefresh={handleItemRefresh}
|
||||
backendUrl={backendUrl}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import React, { useState, useRef } from 'react';
|
||||
import { Item } from '@/lib/db';
|
||||
import { inventoryApi } from '@/lib/api';
|
||||
import { inventoryApi, buildPhotoUrl } from '@/lib/api';
|
||||
import ItemPhotoUpload from '@/components/ItemPhotoUpload';
|
||||
import { X, Camera, Trash2 } from 'lucide-react';
|
||||
import { toast } from 'react-hot-toast';
|
||||
@@ -12,6 +12,7 @@ interface ItemDetailModalProps {
|
||||
onClose: () => void;
|
||||
onPhotoUpdated?: (photo: { thumbnail_url: string; full_url: string; uploaded_at: string }) => void;
|
||||
onItemRefresh?: () => void;
|
||||
backendUrl?: string;
|
||||
}
|
||||
|
||||
export default function ItemDetailModal({
|
||||
@@ -19,6 +20,7 @@ export default function ItemDetailModal({
|
||||
onClose,
|
||||
onPhotoUpdated,
|
||||
onItemRefresh,
|
||||
backendUrl = '',
|
||||
}: ItemDetailModalProps) {
|
||||
const [showPhotoUpload, setShowPhotoUpload] = useState(false);
|
||||
const [currentPhoto, setCurrentPhoto] = useState<{ thumbnail_url: string; full_url: string } | null>(
|
||||
@@ -119,7 +121,7 @@ export default function ItemDetailModal({
|
||||
<div className="space-y-3">
|
||||
<div className="relative bg-slate-900/50 rounded-2xl overflow-hidden border border-slate-800/50 aspect-video max-h-96">
|
||||
<img
|
||||
src={currentPhoto.full_url}
|
||||
src={buildPhotoUrl(backendUrl, currentPhoto.full_url)}
|
||||
alt={item.name}
|
||||
className="w-full h-full object-contain"
|
||||
/>
|
||||
|
||||
@@ -47,6 +47,11 @@ export const getBackendUrl = async () => {
|
||||
return `http://${host}:${config.BACKEND_PORT}`;
|
||||
};
|
||||
|
||||
export const buildPhotoUrl = (backendUrl: string, photoPath: string): string => {
|
||||
if (!photoPath) return '';
|
||||
return `${backendUrl}${photoPath}`;
|
||||
};
|
||||
|
||||
/**
|
||||
* [C-01] Axios instance cu JWT Bearer token în header
|
||||
* și interceptor pentru 401 Unauthorized (token expired)
|
||||
|
||||
Reference in New Issue
Block a user