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(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
1
.gitignore
vendored
@@ -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
|
||||||
|
|||||||
@@ -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')
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -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"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
Reference in New Issue
Block a user