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:
2026-04-22 08:50:25 +03:00
parent 1c13ebd76f
commit 64d177e791
10 changed files with 79 additions and 70 deletions

View File

@@ -95,7 +95,9 @@
"Bash(npx vitest *)", "Bash(npx vitest *)",
"Bash(sed -i 's/jest\\\\.fn\\(\\)/vi.fn\\(\\)/g' tests/hooks/useAIExtraction.test.ts)", "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(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
View File

@@ -148,6 +148,7 @@ backend/.env.test
# Test images uploaded during development # Test images uploaded during development
_images.tests/ _images.tests/
_images/ _images/
images/
# ── Local AI Tooling & Persistent Paths ────────────────────── # ── Local AI Tooling & Persistent Paths ──────────────────────
.tool_paths .tool_paths

View File

@@ -503,54 +503,48 @@ def _auto_save_photo_from_extraction(
"reason": "Empty image bytes" "reason": "Empty image bytes"
} }
# Graceful skip if crop_bounds is None # Validate crop_bounds (if provided)
if crop_bounds is None: crop_bounds_validated = None
log.info(f"Auto-save photo for item {item_id}: crop_bounds is None, skipping") if crop_bounds is not None:
return { if not isinstance(crop_bounds, dict):
"status": "skipped", log.warning(f"Auto-save photo for item {item_id}: crop_bounds is not a dict, skipping")
"reason": "crop_bounds is None" return {
} "status": "skipped",
"reason": "crop_bounds must be a dict"
}
# Validate crop_bounds # Check for required keys
if not isinstance(crop_bounds, dict): required_keys = {'x', 'y', 'width', 'height'}
log.warning(f"Auto-save photo for item {item_id}: crop_bounds is not a dict, skipping") if not required_keys.issubset(crop_bounds.keys()):
return { missing = required_keys - set(crop_bounds.keys())
"status": "skipped", log.warning(f"Auto-save photo for item {item_id}: Missing crop_bounds keys: {missing}")
"reason": "crop_bounds must be a dict" return {
} "status": "skipped",
"reason": f"Missing crop_bounds keys: {missing}"
}
# Check for required keys # Validate all values are integers >= 0
required_keys = {'x', 'y', 'width', 'height'} try:
if not required_keys.issubset(crop_bounds.keys()): crop_bounds_validated = {}
missing = required_keys - set(crop_bounds.keys()) for key in required_keys:
log.warning(f"Auto-save photo for item {item_id}: Missing crop_bounds keys: {missing}") val = crop_bounds[key]
return { # Convert to int if it's numeric
"status": "skipped", if isinstance(val, (int, float)):
"reason": f"Missing crop_bounds keys: {missing}" int_val = int(val)
} else:
raise ValueError(f"Non-numeric value for {key}: {val}")
# Validate all values are integers >= 0 if int_val < 0:
try: raise ValueError(f"Negative value for {key}: {int_val}")
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: crop_bounds_validated[key] = int_val
raise ValueError(f"Negative value for {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)}")
except (ValueError, TypeError) as e: return {
log.warning(f"Auto-save photo for item {item_id}: Invalid crop_bounds: {str(e)}") "status": "skipped",
return { "reason": f"Invalid crop_bounds: {str(e)}"
"status": "skipped", }
"reason": f"Invalid crop_bounds: {str(e)}"
}
# Validate rotation_degrees (optional but if provided, must be valid) # Validate rotation_degrees (optional but if provided, must be valid)
if rotation_degrees is not None: if rotation_degrees is not None:
@@ -573,7 +567,7 @@ def _auto_save_photo_from_extraction(
try: try:
# Process image (crop + rotation + compression + thumbnail) # Process image (crop + rotation + compression + thumbnail)
processor = ImageProcessor() 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': if process_result.get('status') != 'success':
error_msg = process_result.get('error', 'Unknown error') error_msg = process_result.get('error', 'Unknown error')

View File

@@ -43,7 +43,7 @@ class ImageProcessor:
self.logger = logger self.logger = logger
def process_photo( 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: ) -> Dict:
""" """
Process a photo with EXIF rotation, smart cropping, and compression. Process a photo with EXIF rotation, smart cropping, and compression.
@@ -51,6 +51,7 @@ class ImageProcessor:
Args: Args:
file_bytes: Raw image file bytes file_bytes: Raw image file bytes
crop_bounds: Optional manual crop bounds {x, y, width, height} crop_bounds: Optional manual crop bounds {x, y, width, height}
rotation_degrees: Optional manual rotation in degrees (applied after crop)
Returns: Returns:
{ {
@@ -136,6 +137,11 @@ class ImageProcessor:
) )
crop_method = 'pillow' 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 # Resize and compress
compressed_bytes = self._resize_and_compress(cropped_image) compressed_bytes = self._resize_and_compress(cropped_image)

View File

@@ -18,10 +18,10 @@
- **CABLE/WIRE LENGTH**: Meters only. Examples: "5m", "10m", "50m" - **CABLE/WIRE LENGTH**: Meters only. Examples: "5m", "10m", "50m"
- **RAM DIMM**: Capacity in GB. Examples: "128GB", "32GB", "8GB" - **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. - `<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) - `<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. - `<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):** **Item Examples (WITH HUMAN-READABLE SIZES):**
- `1.6TB NVMe HPE U.3 P66093-002` (not 1600GB) - `1.6TB NVMe HPE U.3 P66093-002` (not 1600GB)

View File

@@ -2,7 +2,7 @@
import { useState, useEffect, useCallback } from 'react'; import { useState, useEffect, useCallback } from 'react';
import { db, Item } from '@/lib/db'; import { db, Item } from '@/lib/db';
import { inventoryApi } from '@/lib/api'; import { inventoryApi, getBackendUrl } from '@/lib/api';
import PageShell from '@/components/PageShell'; import PageShell from '@/components/PageShell';
import Scanner from '@/components/Scanner'; import Scanner from '@/components/Scanner';
import StatCard from '@/components/StatCard'; import StatCard from '@/components/StatCard';
@@ -41,6 +41,7 @@ export default function InventoryPage() {
const [inventory, setInventory] = useState<Item[]>([]); const [inventory, setInventory] = useState<Item[]>([]);
const [stats, setStats] = useState<any>(null); const [stats, setStats] = useState<any>(null);
const [currentUser, setCurrentUser] = useState<any | null>(null); const [currentUser, setCurrentUser] = useState<any | null>(null);
const [backendUrl, setBackendUrl] = useState<string>('');
const { const {
searchQuery, searchQuery,
@@ -85,7 +86,8 @@ export default function InventoryPage() {
if (savedUser) { if (savedUser) {
setCurrentUser(JSON.parse(savedUser)); setCurrentUser(JSON.parse(savedUser));
} }
getBackendUrl().then(setBackendUrl);
loadData(); loadData();
}, []); }, []);
@@ -311,6 +313,7 @@ export default function InventoryPage() {
} }
}} }}
categoriesList={categoriesList} categoriesList={categoriesList}
backendUrl={backendUrl}
/> />
</div> </div>

View File

@@ -237,23 +237,15 @@ export default function AIOnboarding({ onCancel, onComplete, categories, invento
</div> </div>
<div className="p-3 space-y-2"> <div className="p-3 space-y-2">
<p className="text-xs text-muted font-normal">Extracted Photo</p> <p className="text-xs text-muted font-normal">Extracted Photo</p>
<div className="flex gap-2"> <label className="flex items-center gap-2 cursor-pointer">
<button <input
onClick={() => updateEditingItem({ _skipPhoto: false })} type="checkbox"
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" checked={!extractedItems[editingIndex]._skipPhoto}
> onChange={(e) => updateEditingItem({ _skipPhoto: !e.target.checked })}
Use Photo className="w-4 h-4 accent-primary cursor-pointer"
</button> />
<button <span className="text-xs text-secondary font-normal">Save this photo with the item</span>
onClick={() => updateEditingItem({ _skipPhoto: true })} </label>
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>
)}
</div> </div>
</div> </div>
)} )}

View File

@@ -2,6 +2,7 @@
import { useState } from 'react'; import { useState } from 'react';
import { Item } from '@/lib/db'; import { Item } from '@/lib/db';
import { buildPhotoUrl } from '@/lib/api';
import { ChevronRight, ChevronDown, Layers, Package } from 'lucide-react'; import { ChevronRight, ChevronDown, Layers, Package } from 'lucide-react';
import { clsx, type ClassValue } from 'clsx'; import { clsx, type ClassValue } from 'clsx';
import { twMerge } from 'tailwind-merge'; import { twMerge } from 'tailwind-merge';
@@ -20,6 +21,7 @@ interface InventoryTableProps {
onItemClick: (item: Item) => void; onItemClick: (item: Item) => void;
onEditCategory?: (category: string) => void; onEditCategory?: (category: string) => void;
categoriesList?: any[]; categoriesList?: any[];
backendUrl?: string;
} }
export default function InventoryTable({ export default function InventoryTable({
@@ -29,7 +31,8 @@ export default function InventoryTable({
onExpandCategory, onExpandCategory,
onItemClick, onItemClick,
onEditCategory, onEditCategory,
categoriesList = [] categoriesList = [],
backendUrl = ''
}: InventoryTableProps) { }: InventoryTableProps) {
const [selectedItemDetail, setSelectedItemDetail] = useState<Item | null>(null); const [selectedItemDetail, setSelectedItemDetail] = useState<Item | null>(null);
const [refreshTrigger, setRefreshTrigger] = useState(0); 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" 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 <img
src={item.photo_path || item.image_url || ''} src={buildPhotoUrl(backendUrl, item.photo_path || item.image_url || '')}
alt={item.name} alt={item.name}
className="w-full h-full object-cover" className="w-full h-full object-cover"
loading="lazy" loading="lazy"
@@ -160,6 +163,7 @@ export default function InventoryTable({
item={selectedItemDetail} item={selectedItemDetail}
onClose={handleCloseDetail} onClose={handleCloseDetail}
onItemRefresh={handleItemRefresh} onItemRefresh={handleItemRefresh}
backendUrl={backendUrl}
/> />
)} )}

View File

@@ -2,7 +2,7 @@
import React, { useState, useRef } from 'react'; import React, { useState, useRef } from 'react';
import { Item } from '@/lib/db'; import { Item } from '@/lib/db';
import { inventoryApi } from '@/lib/api'; import { inventoryApi, buildPhotoUrl } from '@/lib/api';
import ItemPhotoUpload from '@/components/ItemPhotoUpload'; import ItemPhotoUpload from '@/components/ItemPhotoUpload';
import { X, Camera, Trash2 } from 'lucide-react'; import { X, Camera, Trash2 } from 'lucide-react';
import { toast } from 'react-hot-toast'; import { toast } from 'react-hot-toast';
@@ -12,6 +12,7 @@ interface ItemDetailModalProps {
onClose: () => void; onClose: () => void;
onPhotoUpdated?: (photo: { thumbnail_url: string; full_url: string; uploaded_at: string }) => void; onPhotoUpdated?: (photo: { thumbnail_url: string; full_url: string; uploaded_at: string }) => void;
onItemRefresh?: () => void; onItemRefresh?: () => void;
backendUrl?: string;
} }
export default function ItemDetailModal({ export default function ItemDetailModal({
@@ -19,6 +20,7 @@ export default function ItemDetailModal({
onClose, onClose,
onPhotoUpdated, onPhotoUpdated,
onItemRefresh, onItemRefresh,
backendUrl = '',
}: ItemDetailModalProps) { }: ItemDetailModalProps) {
const [showPhotoUpload, setShowPhotoUpload] = useState(false); const [showPhotoUpload, setShowPhotoUpload] = useState(false);
const [currentPhoto, setCurrentPhoto] = useState<{ thumbnail_url: string; full_url: string } | null>( 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="space-y-3">
<div className="relative bg-slate-900/50 rounded-2xl overflow-hidden border border-slate-800/50 aspect-video max-h-96"> <div className="relative bg-slate-900/50 rounded-2xl overflow-hidden border border-slate-800/50 aspect-video max-h-96">
<img <img
src={currentPhoto.full_url} src={buildPhotoUrl(backendUrl, currentPhoto.full_url)}
alt={item.name} alt={item.name}
className="w-full h-full object-contain" className="w-full h-full object-contain"
/> />

View File

@@ -47,6 +47,11 @@ export const getBackendUrl = async () => {
return `http://${host}:${config.BACKEND_PORT}`; 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 * [C-01] Axios instance cu JWT Bearer token în header
* și interceptor pentru 401 Unauthorized (token expired) * și interceptor pentru 401 Unauthorized (token expired)