diff --git a/CLAUDE.md b/CLAUDE.md index 1c0398b..86bc9d0 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -6,6 +6,41 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co Film Photography Tracker is a Progressive Web App (PWA) for tracking film photography metadata. Built with React 18, TypeScript, Vite, and Material-UI, it helps photographers record and organize shots with exposure details, location data, and camera information. +## Feature Documentation Process + +When completing work on a feature or making significant changes: + +### Completed Features +1. **Document the feature** in `docs/features/done_F-{n}.md` where `n` is the next sequential number +2. **Include in the documentation:** + - Feature overview and key components + - Technical details (files changed, new APIs, data models) + - User benefits and use cases + - Testing coverage + - Commits included + - Migration notes (if applicable) + - Future enhancement ideas +3. **Naming convention:** `done_F-1.md`, `done_F-2.md`, etc. +4. **Delete task plans** from `docs/tasks/todo/` once implemented + +### Planned Features +1. **Create plan** in `docs/tasks/todo/plan_F-{n}.md` for future features +2. **Include in the plan:** + - Problem statement + - Proposed solution + - Implementation steps + - Benefits and trade-offs + - Effort estimate +3. **Naming convention:** `plan_F-5.md`, `plan_F-6.md`, etc. +4. **Move to done_** once implemented (renumber if needed) + +**IMPORTANT for Claude Code plan mode:** When entering plan mode, ALWAYS write the final plan to `docs/tasks/todo/plan_F-{n}.md` (NOT to `~/.claude/plans/`). This ensures plans are tracked in the repository. + +### Feature Numbering +- Start from F-1 and increment sequentially +- Completed features use `done_` prefix +- Planned features use `plan_` prefix + ## Key Commands ### Development diff --git a/docs/features/done_F-1.md b/docs/features/done_F-1.md new file mode 100644 index 0000000..1c48bef --- /dev/null +++ b/docs/features/done_F-1.md @@ -0,0 +1,254 @@ +# Feature F-1: Advanced Lens Management & Smart Focal Length System + +**Status:** ✅ Completed +**Branch:** `new_features` +**Completion Date:** 2026-02-17 + +## Overview + +This feature introduces comprehensive lens management capabilities and intelligent focal length tracking with dynamic UI controls that adapt to lens type (prime vs zoom). + +## Key Components + +### 1. Lens Management System + +#### Lens Model +Added new `Lens` interface to support both prime and zoom lenses: + +```typescript +interface Lens { + id: string; + name: string; + maxAperture: string; // e.g., "f/1.4", "f/2.8" + focalLength?: number; // For prime lenses (e.g., 50) + focalLengthMin?: number; // For zoom lenses (e.g., 24) + focalLengthMax?: number; // For zoom lenses (e.g., 70) + createdAt: Date; +} +``` + +#### Lens Management Screen +- **New Component:** `src/components/LensManagementScreen.tsx` +- Full CRUD operations for lens library +- Validation: Prevents creating lenses that are both prime AND zoom +- Auto-disabling fields: Prime/zoom fields disable each other +- Material-UI Alert component for inline validation errors +- Grid layout with lens cards showing: + - Lens name + - Max aperture + - Focal length (single value for prime, range for zoom) + +### 2. Exposure Index (EI) Override + +Added ability to override film ISO on a per-exposure basis: + +- New `ei` field in `Exposure` type (optional number) +- EI selector in camera settings with: + - "Use film ISO" default option + - Preset values (50, 100, 200, 400, 800, 1600, 3200, 6400) + - Custom value input for non-standard EI values +- Exports include EI data for EXIF writing + +### 3. Smart Focal Length Slider + +#### Reusable Component +- **New Component:** `src/components/FocalLengthSlider.tsx` +- Extracted from duplicated code in CameraScreen and DetailsScreen +- Saved ~220 lines of code through component reuse + +#### Dynamic Behavior Based on Lens Type + +**No Lens Selected:** +- Full slider range: 1-200mm +- Step: 1mm +- Fully active and editable +- Manual input field accepts any value + +**Prime Lens Selected (e.g., 50mm):** +- Slider locked at lens focal length +- Disabled with grey styling +- Label shows "(Prime lens - fixed)" +- Manual input field disabled +- Auto-sets to lens focal length when lens is selected + +**Zoom Lens Selected (e.g., 80-200mm):** +- Full slider range visible (1-200mm) +- Movement constrained to zoom range (can't slide below 80mm or above 200mm) +- Step: 5mm for easier zoom adjustment +- Label shows zoom range: "(80-200mm zoom)" +- Manual input field constrained to zoom range +- Auto-sets to midpoint when lens is selected (rounded to nearest 5mm) + +#### Slider UI Features +- Common focal length marks: 1, 28, 50, 85, 135, 200 +- No "mm" suffix on labels (cleaner look) +- Value indicator tooltip while dragging +- Smooth constraint handling (can't drag outside allowed range) + +### 4. Mid-Roll Lens Changes + +Users can now change lenses mid-roll: + +- **Film Roll Level:** `currentLensId` field tracks active lens +- **Exposure Level:** Each exposure stores its own `lensId` +- Lens selection in camera settings updates film roll's current lens +- Lens change dialog accessible from camera screen +- New exposures automatically use currently selected lens + +### 5. Aperture Filtering by Lens + +Aperture dropdown now respects lens maximum aperture: + +- If lens has max aperture of f/2.8, only shows f/2.8 through f/22 +- Prevents selecting physically impossible apertures +- Works in both camera screen and details/editing screen + +### 6. Enhanced Export/Import + +Updated metadata export format to v2.0.0: + +```json +{ + "version": "2.0.0", + "filmRoll": { + "ei": 400, + "currentLensId": "lens-id" + }, + "exposures": [ + { + "ei": 400, + "lensId": "lens-id", + "lensName": "Helios 44-2", // Denormalized for external tools + "focalLength": 58, + "aperture": "f/2", + "shutterSpeed": "1/125" + // ... other fields + } + ] +} +``` + +**Denormalized Lens Name:** +- Each exposure includes `lensName` for easier external tool usage +- Python metadata script reads lens name directly from exposure +- No need to look up lens by ID in external tools + +### 7. Python Metadata Script Updates + +Updated `apply_filmroll_metadata.py` to support: +- EI override (uses EI if set, falls back to film ISO) +- Lens name from exposure data +- Per-exposure focal length +- All data properly written to TIF file EXIF tags + +### 8. Storage Updates + +**IndexedDB:** +- Added `lenses` object store +- Migration support for new lens-related fields +- Proper serialization/deserialization of lens data + +**Storage API:** +- `saveLens(lens: Lens): Promise` +- `getLenses(): Promise` +- `deleteLens(id: string): Promise` + +### 9. UI/UX Improvements + +**Three-Tab Navigation:** +- Film Rolls (existing) +- Cameras (existing) +- **Lenses (new)** - Flat top-level navigation + +**Material-UI Alerts:** +- Replaced all `alert()` calls with inline MUI Alert components +- Better UX with dismissible error messages +- Consistent styling with app theme + +**Settings Dialog Enhancement:** +- Lens selector dropdown +- EI/Exposure Index selector +- Focal length slider with lens-aware behavior +- All settings in one place + +## Testing + +### E2E Test Coverage + +**New Test Suite:** +- `e2e/lens-management.spec.ts` - 62 new lines +- Lens CRUD operations +- Prime vs zoom lens validation +- Aperture filtering +- Mid-roll lens changes + +**Updated Test Suites:** +- `e2e/app-navigation.spec.ts` - Added lens tab navigation +- `e2e/photography-workflow.spec.ts` - Lens integration tests +- `e2e/utils/page-objects.ts` - Lens management page objects +- `e2e/utils/test-data.ts` - Test lens data + +**CI Integration:** +- E2E tests run on push to main +- Tests across 5 browsers (Chromium, Firefox, WebKit, Mobile Chrome, Mobile Safari) + +## Technical Details + +### Files Changed (27 files) +- **Added:** 3 new files (~750 lines) + - `src/components/LensManagementScreen.tsx` + - `src/components/FocalLengthSlider.tsx` + - `e2e/lens-management.spec.ts` + +- **Modified:** 24 files (~1,300 lines added, ~230 removed) + - Core: App.tsx, types.ts, storage layers + - Screens: CameraScreen, DetailsScreen, SetupScreen, GalleryScreen, MainScreen + - Utils: exportImport, indexedDBStorage + - Tests: Multiple E2E test files + +### Net Impact +- **+2,005 lines added** +- **-232 lines removed** +- **Net: +1,773 lines** + +## User Benefits + +1. **Professional Lens Library:** Track all lenses in one place +2. **Accurate Metadata:** Focal length and lens info in every exposure +3. **Smart Constraints:** UI prevents invalid settings (e.g., f/1.4 on f/2.8 lens) +4. **Flexible Workflows:** Change lenses mid-roll, override ISO with EI +5. **Export-Ready:** All metadata properly formatted for EXIF tools +6. **Better UX:** Inline errors, adaptive controls, cleaner interface + +## Migration Notes + +- **Backward Compatible:** Existing exposures without lens data work fine +- **Automatic Migration:** IndexedDB migration handles schema updates +- **Export Version:** New exports use v2.0.0 format, old format still importable + +## Future Enhancements (Potential) + +- [ ] Lens image/photo upload +- [ ] Lens rental tracking (date ranges) +- [ ] Lens maintenance notes +- [ ] Filter exposures by lens +- [ ] Lens usage statistics +- [ ] Import lens library from external sources + +## Related Documentation + +- See `CLAUDE.md` for updated project architecture +- See `apply_filmroll_metadata.py` docstrings for EXIF details +- See E2E tests for usage examples + +## Commits + +``` +e5dbab3 refactor: extract focal legth slider to a component +f20a6e1 fix: fix slider +ff17643 fix: fix focal legth locked to available values +015dfb1 ci: add e2e tests to ci +8243421 test: fix e2e tests +4e9287c test: fix e2e tests +2155c02 feat: add lenses, add EI, add focal length +``` diff --git a/docs/tasks/todo/plan_F-2.md b/docs/tasks/todo/plan_F-2.md new file mode 100644 index 0000000..8031995 --- /dev/null +++ b/docs/tasks/todo/plan_F-2.md @@ -0,0 +1,486 @@ +# Plan: JSON-with-Images Export/Import Feature (F-2) + +## Overview +Add a new export/import format that embeds all images directly in a single JSON file, eliminating the need to manage multiple files. Film rolls imported using this format will be prefixed with "[IMPORTED]". + +## User Choices +- Keep existing "JSON Only" option and add "JSON with Images" as 4th export method +- Use separate import options: "Import from Local Files" (multi-file) and "Import JSON with Images" (single-file) +- Show file size warning (with estimated size) before exporting if JSON will be >10MB + +## Implementation Details + +### 1. Type Definitions (`src/types.ts`) + +Add new export format interface after the existing `ExposureExportData`: + +```typescript +export interface ExportDataWithImages { + filmRoll: FilmRoll; + exposures: ExposureWithImageData[]; + exportedAt: string; + version: string; + exportType: 'with-images'; +} + +export interface ExposureWithImageData { + id: string; + filmRollId: string; + exposureNumber: number; + aperture: string; + shutterSpeed: string; + additionalInfo: string; + imageData?: string; // Base64 data URL embedded directly + location?: { + latitude: number; + longitude: number; + address?: string; + }; + capturedAt: string; + ei?: number; + lensId?: string; + lensName?: string; + focalLength?: number; +} +``` + +### 2. Export/Import Logic (`src/utils/exportImport.ts`) + +**Add helper function to estimate file size:** +```typescript +// Add at top level, before exportUtils object +const estimateExportSize = (filmRoll: FilmRoll, exposures: Exposure[]): number => { + const filmExposures = exposures.filter(e => e.filmRollId === filmRoll.id); + let totalSize = 0; + + // Estimate base64 image sizes + filmExposures.forEach(exp => { + if (exp.imageData) { + // Base64 data URL format: data:image/jpeg;base64, + // Extract just the base64 part to get accurate size + const base64Data = exp.imageData.split(',')[1] || ''; + totalSize += base64Data.length; + } + }); + + // Add overhead for JSON structure (~5KB per exposure) + totalSize += filmExposures.length * 5000; + + return totalSize; +}; +``` + +**Add export method to exportUtils object (after exportJsonOnly):** +```typescript +exportJsonWithImages: async (filmRoll: FilmRoll, exposures: Exposure[], lenses: Lens[]): Promise => { + try { + const filmExposures = exposures.filter(e => e.filmRollId === filmRoll.id); + + // Check file size and warn user if >10MB + const estimatedSize = estimateExportSize(filmRoll, exposures); + const sizeMB = (estimatedSize / (1024 * 1024)).toFixed(1); + + if (estimatedSize > 10 * 1024 * 1024) { + const confirmDownload = window.confirm( + `This export will be approximately ${sizeMB} MB.\n\n` + + `Large files may take time to download, especially on mobile data.\n\n` + + `Continue with export?` + ); + + if (!confirmDownload) { + return; + } + } + + // Prepare export data with embedded images + const exportExposures: ExposureWithImageData[] = filmExposures.map(exposure => { + const lens = lenses.find(l => l.id === exposure.lensId); + + return { + id: exposure.id, + filmRollId: exposure.filmRollId, + exposureNumber: exposure.exposureNumber, + aperture: exposure.aperture, + shutterSpeed: exposure.shutterSpeed, + additionalInfo: exposure.additionalInfo, + imageData: exposure.imageData, // Include base64 directly + location: exposure.location, + capturedAt: exposure.capturedAt.toISOString(), + ei: exposure.ei, + lensId: exposure.lensId, + lensName: lens?.name, + focalLength: exposure.focalLength + }; + }); + + const exportData: ExportDataWithImages = { + filmRoll, + exposures: exportExposures, + exportedAt: new Date().toISOString(), + version: '2.0.0', + exportType: 'with-images' + }; + + const jsonString = JSON.stringify(exportData, null, 2); + const fileName = `${filmRoll.name.replace(/\s+/g, '_')}_with_images.json`; + + fileUtils.downloadData(jsonString, fileName, 'application/json'); + + } catch (error) { + console.error('JSON with images export error:', error); + alert('Error during export. The file may be too large. Try exporting with separate files instead.'); + } +}, +``` + +**Add import method to exportUtils object (after importFromLocal):** +```typescript +importJsonWithImages: async (file: File): Promise<{ filmRoll: FilmRoll; exposures: Exposure[] } | null> => { + try { + // Read the JSON file + const jsonText = await new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = () => resolve(reader.result as string); + reader.onerror = () => reject(new Error('Failed to read file')); + reader.readAsText(file); + }); + + const exportData: ExportDataWithImages = JSON.parse(jsonText); + + // Validate format + if (exportData.exportType !== 'with-images') { + throw new Error('Invalid format. Please select a JSON file exported with the "JSON with Images" option.'); + } + + // Convert exposures back to internal format + const exposures: Exposure[] = exportData.exposures.map(exp => ({ + id: exp.id, + filmRollId: exp.filmRollId, + exposureNumber: exp.exposureNumber, + aperture: exp.aperture, + shutterSpeed: exp.shutterSpeed, + additionalInfo: exp.additionalInfo, + imageData: exp.imageData, + location: exp.location, + capturedAt: new Date(exp.capturedAt), + ei: exp.ei, + lensId: exp.lensId, + focalLength: exp.focalLength + })); + + // Prefix film roll name with [IMPORTED] + const filmRoll: FilmRoll = { + ...exportData.filmRoll, + name: `[IMPORTED] ${exportData.filmRoll.name}`, + createdAt: new Date(exportData.filmRoll.createdAt) + }; + + return { filmRoll, exposures }; + + } catch (error) { + console.error('Import error:', error); + if (error instanceof Error && error.message.includes('Invalid format')) { + alert(error.message); + } else { + alert('Error during import. Please check that you selected a valid JSON file exported with images.'); + } + return null; + } +} +``` + +**Update the ExportData type export at the top:** +```typescript +// Add this export after the ExportDataWithImages interface +export type { ExportData, ExposureExportData, ExportDataWithImages, ExposureWithImageData }; +``` + +### 3. UI Updates (`src/components/GalleryScreen.tsx`) + +**Update state (line 68-69):** +```typescript +const [exportMethod, setExportMethod] = useState<'local' | 'googledrive' | 'jsononly' | 'jsonwithimages'>('local'); +const [importMethod, setImportMethod] = useState<'local' | 'googledrive' | 'jsonwithimages'>('local'); +``` + +**Add new ref for JSON-with-images import (after line 71):** +```typescript +const jsonWithImagesInputRef = useRef(null); +``` + +**Update handleExport function (replace lines 97-120):** +```typescript +const handleExport = async () => { + if (exportMethod !== 'jsononly' && exportMethod !== 'jsonwithimages' && !exportFolderName.trim()) { + alert('Please enter a folder name'); + return; + } + + setIsProcessing(true); + try { + if (exportMethod === 'googledrive') { + await googleDriveUtils.exportToGoogleDrive(filmRoll, filmExposures, lenses, exportFolderName); + } else if (exportMethod === 'jsononly') { + await exportUtils.exportJsonOnly(filmRoll, filmExposures, lenses); + } else if (exportMethod === 'jsonwithimages') { + await exportUtils.exportJsonWithImages(filmRoll, filmExposures, lenses); + } else { + await exportUtils.exportToLocal(filmRoll, filmExposures, lenses, exportFolderName); + } + setShowExportDialog(false); + setExportFolderName(''); + } catch (error) { + console.error('Export failed:', error); + alert('Export failed. Please try again.'); + } finally { + setIsProcessing(false); + } +}; +``` + +**Update handleImport function (replace lines 122-150):** +```typescript +const handleImport = async () => { + setIsProcessing(true); + try { + let result: { filmRoll: FilmRoll; exposures: Exposure[] } | null = null; + + if (importMethod === 'googledrive') { + if (!importFolderName.trim()) { + alert('Please enter a folder name'); + setIsProcessing(false); + return; + } + result = await googleDriveUtils.importFromGoogleDrive(importFolderName); + } else if (importMethod === 'jsonwithimages') { + // Trigger file input for JSON with images + jsonWithImagesInputRef.current?.click(); + setIsProcessing(false); + return; + } else { + // Trigger file input for local multi-file import + fileInputRef.current?.click(); + setIsProcessing(false); + return; + } + + if (result && onDataImported) { + // Save imported data + await storage.saveFilmRoll(result.filmRoll); + for (const exposure of result.exposures) { + await storage.saveExposure(exposure); + } + + onDataImported(result.filmRoll, result.exposures); + setShowImportDialog(false); + setImportFolderName(''); + } + } catch (error) { + console.error('Import failed:', error); + alert('Import failed. Please try again.'); + } finally { + setIsProcessing(false); + } +}; +``` + +**Add new handler for JSON-with-images file input (after handleFileSelect, around line 160):** +```typescript +const handleJsonWithImagesFileSelect = async (event: React.ChangeEvent) => { + const file = event.target.files?.[0]; + if (!file) return; + + try { + const result = await exportUtils.importJsonWithImages(file); + + if (result && onDataImported) { + // Save imported data + await storage.saveFilmRoll(result.filmRoll); + for (const exposure of result.exposures) { + await storage.saveExposure(exposure); + } + + onDataImported(result.filmRoll, result.exposures); + setShowImportDialog(false); + alert(`Successfully imported film roll: ${result.filmRoll.name}\nExposures: ${result.exposures.length}`); + } + } catch (error) { + console.error('Import failed:', error); + alert('Import failed. Please check the file and try again.'); + } finally { + // Reset file input + event.target.value = ''; + } +}; +``` + +**Update Export Dialog (find the RadioGroup in export dialog, around line 250-280):** + +Add 4th radio button after the "JSON Only" option: +```typescript +} + label="JSON with Images" +/> +``` + +Update the help text below RadioGroup to mention the new option: +```typescript + + Local: Creates metadata.json + image files
+ JSON Only: Metadata without images
+ JSON with Images: Single JSON file with embedded images
+ Google Drive: Requires API setup +
+``` + +Update folder name TextField to disable for both jsononly and jsonwithimages: +```typescript + setExportFolderName(e.target.value)} + disabled={exportMethod === 'jsononly' || exportMethod === 'jsonwithimages'} + helperText={ + exportMethod === 'jsononly' || exportMethod === 'jsonwithimages' + ? 'Not needed for single-file export' + : 'Name for organizing downloaded files' + } + sx={{ mt: 2 }} +/> +``` + +**Update Import Dialog (find the RadioGroup in import dialog, around line 320-340):** + +Add 3rd radio button after the "Local" option: +```typescript +} + label="Import JSON with Images" +/> +``` + +Update the help text: +```typescript + + Local Files: Select metadata.json + image files
+ JSON with Images: Select single JSON file with embedded images
+ Google Drive: Requires API setup +
+``` + +Update folder name TextField to hide for jsonwithimages: +```typescript +{importMethod !== 'jsonwithimages' && ( + setImportFolderName(e.target.value)} + disabled={importMethod === 'local'} + helperText={ + importMethod === 'local' + ? 'Click Import to select files from your device' + : 'Enter the exact folder name from Google Drive' + } + sx={{ mt: 2 }} + /> +)} +``` + +**Add hidden file input for JSON-with-images import (after the existing fileInputRef input, around line 380):** +```typescript + +``` + +## Critical Files + +1. `src/types.ts` - Add new interfaces for JSON-with-images format +2. `src/utils/exportImport.ts` - Add new export/import methods +3. `src/components/GalleryScreen.tsx` - Update UI with new options and handlers + +## Testing & Verification + +### Manual Testing Flow: + +1. **Export Testing:** + - Create a film roll with 3-5 exposures (with images) + - Open Gallery → Export + - Select "JSON with Images" option + - Verify folder name field is disabled + - Click Export + - If total size >10MB, verify warning dialog appears with size estimate + - Verify single JSON file downloads with name `{filmroll}_with_images.json` + - Open the JSON file and verify `exportType: 'with-images'` is present + - Verify all `imageData` fields contain base64 data URLs + +2. **Import Testing:** + - Open Gallery → Import + - Select "Import JSON with Images" option + - Verify folder name field is hidden + - Click Import and select the exported JSON file + - Verify film roll appears with "[IMPORTED]" prefix + - Verify all exposures are restored with images + - Check that all metadata (aperture, shutter, location, etc.) is preserved + +3. **Size Warning Testing:** + - Create film roll with 20+ high-resolution exposures + - Export as "JSON with Images" + - Verify size warning appears if >10MB + - Test both "Continue" and "Cancel" options + +4. **Backward Compatibility:** + - Verify existing "Local" export still works (multi-file) + - Verify existing "JSON Only" export still works (metadata only) + - Verify existing "Import from Local Files" still works with old exports + +5. **Error Handling:** + - Try importing a regular metadata.json (without images) as "JSON with Images" - should show error + - Try importing corrupted JSON - should show friendly error + - Try exporting with no exposures - should handle gracefully + +### E2E Test Ideas (for future implementation): + +```typescript +// Add to film-roll-management.spec.ts or new file +test('JSON with images export/import', async ({ page }) => { + // Create film roll with exposures + // Navigate to gallery + // Click export, select "JSON with Images" + // Wait for download + // Click import, select "Import JSON with Images" + // Upload the file + // Verify "[IMPORTED]" prefix in film roll name + // Verify all exposures present +}); +``` + +## Benefits + +- Single file to manage (easier for users than multiple files) +- No risk of losing images when moving files around +- Simpler sharing (one file via email, Telegram, etc.) +- Self-contained backup format +- Works alongside existing export methods + +## Trade-offs + +- Larger file size (base64 encoding adds ~33% overhead) +- May be slower to export/import with many large images +- Not suitable for external tools like Python metadata script (use "Local" export for that) +- File size warnings needed for mobile data users + +## Version Tracking + +- Export format version stays at "2.0.0" (same data structure as existing exports) +- New `exportType: 'with-images'` field distinguishes the format +- Fully backward compatible (doesn't break existing code) diff --git a/docs/tasks/todo/plan_F-3.md b/docs/tasks/todo/plan_F-3.md new file mode 100644 index 0000000..50e00ff --- /dev/null +++ b/docs/tasks/todo/plan_F-3.md @@ -0,0 +1,249 @@ +# Plan: Descale Large Uploaded Images (F-3) + +## Problem Statement + +Currently, images captured via the camera are automatically scaled down to a maximum of 1280px (see `camera.captureImage()` in `src/utils/camera.ts:89-141`). However, when users upload images from their gallery using the file picker, the images are read as-is via `fileUtils.fileToBase64()` without any scaling. This creates inconsistency: + +- **Camera captures**: Scaled to max 1280px, quality 0.7-0.8, optimized for storage +- **Gallery uploads**: Full resolution (could be 4000px+), much larger file sizes, inconsistent quality + +This causes: +1. Larger storage usage for gallery uploads +2. Slower performance when displaying/exporting gallery images +3. Inconsistent image sizes in the same film roll +4. Potential memory issues on mobile devices + +## Proposed Solution + +Add an image scaling utility function that processes both camera captures and gallery uploads uniformly. The function should: +1. Load the image into an HTML Image element +2. Calculate scaled dimensions (max 1280px, preserve aspect ratio) +3. Draw to canvas with scaled dimensions +4. Apply appropriate JPEG compression (0.7-0.8 quality) +5. Return base64 data URL + +This matches the existing `camera.captureImage()` behavior but works with any image source (File, base64, etc.). + +## Implementation Steps + +### 1. Create Image Scaling Utility (`src/utils/camera.ts`) + +Add a new utility function `fileUtils.scaleImageFile()` that: +- Takes a File object as input +- Returns a Promise with scaled base64 data URL +- Uses the same scaling logic as `camera.captureImage()`: + - Max dimension: 1280px + - Preserve aspect ratio + - JPEG quality: 0.7 for larger images (>640x480), 0.8 for smaller +- Handles errors gracefully + +**Implementation approach:** +```typescript +// Add to fileUtils object in src/utils/camera.ts +scaleImageFile: (file: File): Promise => { + return new Promise((resolve, reject) => { + // Create image element to load file + const img = new Image(); + const url = URL.createObjectURL(file); + + img.onload = () => { + try { + URL.revokeObjectURL(url); // Clean up + + // Same scaling logic as camera.captureImage() + const maxSize = 1280; + let canvasWidth = img.width; + let canvasHeight = img.height; + + if (img.width > maxSize || img.height > maxSize) { + const aspectRatio = img.width / img.height; + if (img.width > img.height) { + canvasWidth = maxSize; + canvasHeight = maxSize / aspectRatio; + } else { + canvasHeight = maxSize; + canvasWidth = maxSize * aspectRatio; + } + } + + const canvas = document.createElement('canvas'); + canvas.width = canvasWidth; + canvas.height = canvasHeight; + + const ctx = canvas.getContext('2d'); + if (!ctx) { + reject(new Error('Unable to create canvas context')); + return; + } + + ctx.drawImage(img, 0, 0, canvasWidth, canvasHeight); + + // Same quality calculation as camera.captureImage() + const quality = canvasWidth * canvasHeight > 640 * 480 ? 0.7 : 0.8; + const dataURL = canvas.toDataURL('image/jpeg', quality); + + if (!dataURL || dataURL.length < 100) { + reject(new Error('Failed to generate image data')); + return; + } + + resolve(dataURL); + } catch (error) { + reject(error); + } + }; + + img.onerror = () => { + URL.revokeObjectURL(url); + reject(new Error('Failed to load image file')); + }; + + img.src = url; + }); +} +``` + +### 2. Update CameraScreen Gallery Upload (`src/components/CameraScreen.tsx`) + +Replace the direct `fileUtils.fileToBase64(file)` call with the new scaling function: + +**Current code (line 276):** +```typescript +const imageData = await fileUtils.fileToBase64(file); +``` + +**New code:** +```typescript +const imageData = await fileUtils.scaleImageFile(file); +``` + +This ensures gallery uploads are scaled the same way as camera captures. + +### 3. Update DetailsScreen Image Change (`src/components/DetailsScreen.tsx`) + +Replace the `fileUtils.fileToBase64(file)` call with the new scaling function: + +**Current code (line 90):** +```typescript +const imageData = await fileUtils.fileToBase64(file); +``` + +**New code:** +```typescript +const imageData = await fileUtils.scaleImageFile(file); +``` + +This ensures image replacements are also scaled consistently. + +### 4. Keep fileToBase64 for Non-Image Uses + +The `fileUtils.fileToBase64()` function should remain unchanged as it may be used for other purposes (e.g., reading JSON files during import). The new `scaleImageFile()` is specifically for image processing. + +## Critical Files + +1. `src/utils/camera.ts` - Add `fileUtils.scaleImageFile()` function +2. `src/components/CameraScreen.tsx` - Update line 276 to use new scaling function +3. `src/components/DetailsScreen.tsx` - Update line 90 to use new scaling function + +## Benefits + +- **Consistent image sizes**: All images (camera + gallery) are scaled uniformly to max 1280px +- **Reduced storage usage**: Gallery uploads no longer store full 4000px+ images +- **Better performance**: Smaller images load faster, especially on mobile +- **Lower memory usage**: Prevents memory issues when handling large images +- **Predictable exports**: All exported images have consistent sizes + +## Trade-offs + +- **Quality loss for high-res uploads**: Users uploading 4000px images will see them downscaled + - Mitigation: This matches the camera capture behavior, so it's consistent + - Users who need full resolution should use external tools +- **Additional processing time**: Scaling takes ~100-500ms per image + - Mitigation: Still fast enough for good UX, and the storage savings are worth it + +## Testing & Verification + +### Manual Testing Flow: + +1. **Test Gallery Upload Scaling:** + - Open camera screen + - Click gallery icon to upload an image + - Select a high-resolution image (2000px+ from a DSLR or scanner) + - Verify the image is scaled down to max 1280px + - Check the base64 data length is reasonable (~200-400KB range) + +2. **Test Camera Capture (Regression):** + - Capture an image using the live camera + - Verify it still works correctly + - Check the image size is still max 1280px + - Ensure no new errors occur + +3. **Test Image Replacement in Details:** + - Open an exposure in details screen + - Click to replace the image + - Select a high-resolution image + - Verify the image is scaled down + - Check the aspect ratio is preserved + +4. **Test Different Image Sizes:** + - Upload a small image (500x500px) - should not upscale + - Upload a wide image (4000x2000px) - should scale to 1280x640px + - Upload a tall image (2000x4000px) - should scale to 640x1280px + +5. **Test Quality Settings:** + - Upload a large image and verify quality is 0.7 + - Upload a small image and verify quality is 0.8 + - Visually inspect that images look good + +6. **Test Error Handling:** + - Try uploading a corrupted image file + - Try uploading a non-image file (should be caught by earlier validation) + - Verify friendly error messages + +### Performance Testing: + +1. **Measure Scaling Time:** + - Upload several large images (3000px+) + - Log processing time in console + - Verify < 1 second per image + +2. **Memory Usage:** + - Upload 10+ large images in a row + - Monitor browser memory usage + - Ensure no memory leaks (URL.revokeObjectURL is called) + +### E2E Test Ideas (for future implementation): + +```typescript +// Add to photography-workflow.spec.ts +test('Gallery upload scales large images', async ({ page }) => { + // Create film roll + // Navigate to camera screen + // Upload high-resolution image via file input + // Verify image is displayed + // Navigate to details screen + // Check image dimensions are max 1280px +}); +``` + +## Edge Cases to Consider + +1. **Very small images** (< 640x480): Should not upscale, maintain quality 0.8 +2. **Square images** (1000x1000): Should scale to 1280x1280 if > 1280 +3. **Non-JPEG formats** (PNG, WebP): Canvas toDataURL will convert to JPEG +4. **Animated GIFs**: Will convert to static JPEG (first frame) +5. **EXIF orientation**: May need rotation handling (test with phone photos) + +## Future Enhancements + +1. **EXIF orientation handling**: Respect EXIF rotation tags from phone photos +2. **Configurable max size**: Allow users to set max image dimension in settings +3. **Quality settings**: Let users choose quality vs. size trade-off +4. **WebP support**: Use WebP instead of JPEG for better compression +5. **Background processing**: Use Web Workers for large batch uploads + +## Version Notes + +- No version bump needed (internal optimization) +- Backward compatible (existing images unchanged) +- Export format unchanged diff --git a/docs/tasks/todo/plan_F-4.md b/docs/tasks/todo/plan_F-4.md new file mode 100644 index 0000000..7149a5a --- /dev/null +++ b/docs/tasks/todo/plan_F-4.md @@ -0,0 +1,376 @@ +# Plan: Visual Focal Length Simulator with Camera Zoom (F-4) + +## Problem Statement + +When shooting with an iPhone or other phone camera, users want to simulate different focal lengths (e.g., 15mm, 50mm, 85mm) to understand how their scene would look with different lenses. Currently: +- The focal length slider exists but is in a separate settings dialog +- There's no visual feedback showing how the scene changes at different focal lengths +- Users can't easily experiment with framing while looking at the live camera view + +**User requirement:** +- Transparent ruler-style slider overlay on the camera preview +- Slide left/right to change focal length (15mm to 200mm+) +- Image zooms digitally to simulate longer focal lengths +- Shows black letterbox for ultra-wide angles (<24mm) that can't be captured +- Designed for iPhone use (primary use case) +- Simple implementation - avoid complex features if they require significant effort + +## Proposed Solution (Simplified Approach) + +Create a transparent horizontal slider overlay at the bottom of the camera preview that: +1. Controls **digital zoom** on the video element using CSS transform: scale() +2. Uses iPhone 13 standard camera as baseline (24mm equivalent) +3. Shows visual ruler marks for common focal lengths (24, 35, 50, 85, 135mm) +4. Displays black letterbox bars for ultra-wide angles (<24mm) to indicate "not capturable" +5. Updates the focal length value in exposure settings +6. Minimal code changes - mostly UI/CSS work + +**Key simplifications:** +- Use CSS transform scale, NOT MediaStream constraints (avoid camera switching complexity) +- Digital zoom only (no optical zoom or camera switching) +- Fixed baseline at 24mm (iPhone standard camera equivalent) +- Simple calculation: zoom = focal_length / 24 +- No attempt to switch between iPhone multiple cameras (user can do manually if needed) + +## Technical Approach + +### Zoom Calculation (Simple) + +``` +baseline = 24mm (iPhone standard camera) +zoom_factor = current_focal_length / baseline + +Examples: +- 24mm → zoom = 1.0 (no zoom, normal view) +- 50mm → zoom = 2.08 (2x digital zoom) +- 85mm → zoom = 3.54 (3.5x digital zoom) +- 15mm → zoom = 0.625 (letterbox, not capturable) +``` + +For focal lengths < 24mm, show black bars to indicate the field of view is wider than the camera can capture. + +### Visual Design + +**Ruler-style slider:** +``` +┌─────────────────────────────────────────┐ +│ Video Preview │ +│ │ +│ │ +│ [━━━━●━━━━━━━━━━━━━━━━━━━] 50mm │ ← Transparent overlay +└─────────────────────────────────────────┘ + ↑ ↑ ↑ ↑ ↑ ↑ + 15 24 35 50 85 135 +``` + +- Small height (~40-60px) +- Semi-transparent background (rgba(0,0,0,0.4)) +- White ruler marks for key focal lengths +- Current value displayed on the right +- Touch-friendly slider thumb (larger hit area) + +## Implementation Steps + +### 1. Create FocalLengthRulerOverlay Component (`src/components/FocalLengthRulerOverlay.tsx`) + +New component that renders over the camera view: + +```typescript +interface FocalLengthRulerOverlayProps { + value: number; // Current focal length in mm + onChange: (value: number) => void; + baseline?: number; // Default 24mm for iPhone +} + +// Features: +// - Horizontal slider with ruler marks +// - Semi-transparent background +// - Marks at: 15, 24, 35, 50, 85, 135, 200mm +// - Current value display +// - Touch-friendly (mobile-first) +``` + +**Styling:** +- Position: absolute, bottom: 10px, left: 0, right: 0 +- Height: 50px +- Background: rgba(0, 0, 0, 0.4) +- Border-radius: 8px +- White marks and text +- Slider thumb: 24px circle with white border + +### 2. Update CameraScreen Component + +**Add state for focal length and zoom:** +```typescript +const [simulatedFocalLength, setSimulatedFocalLength] = useState(24); // Default to baseline +const zoomFactor = simulatedFocalLength / 24; // Calculate zoom +const showLetterbox = simulatedFocalLength < 24; // Show black bars for ultra-wide +``` + +**Apply zoom to video element:** +```typescript +