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(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
View File

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

View File

@@ -503,15 +503,9 @@ 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
# 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 {
@@ -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')

View File

@@ -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)

View File

@@ -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)

View File

@@ -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>

View File

@@ -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>
)}

View File

@@ -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}
/>
)}

View File

@@ -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"
/>

View File

@@ -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)