From ff17643ca8b3836de29720a6e021e3a9fbd40b9c Mon Sep 17 00:00:00 2001 From: NikitaZavartsev Date: Tue, 17 Feb 2026 13:43:17 +0100 Subject: [PATCH 1/5] fix: fix focal legth locked to available values --- src/components/CameraScreen.tsx | 131 +++++++++++++++++----- src/components/DetailsScreen.tsx | 140 +++++++++++++++++++----- src/components/LensManagementScreen.tsx | 117 ++++++++++++++++++-- 3 files changed, 322 insertions(+), 66 deletions(-) diff --git a/src/components/CameraScreen.tsx b/src/components/CameraScreen.tsx index 2d6dfd3..4728843 100644 --- a/src/components/CameraScreen.tsx +++ b/src/components/CameraScreen.tsx @@ -569,11 +569,23 @@ getUserMedia: ${!!navigator.mediaDevices?.getUserMedia} const newLensId = e.target.value; setCurrentSettings(prev => { const lens = lenses.find(l => l.id === newLensId); + let newFocalLength = prev.focalLength; + + if (lens) { + if (lens.focalLength !== undefined) { + // Prime lens: set to fixed focal length + newFocalLength = lens.focalLength; + } else if (lens.focalLengthMin !== undefined && lens.focalLengthMax !== undefined) { + // Zoom lens: set to midpoint + newFocalLength = Math.round((lens.focalLengthMin + lens.focalLengthMax) / 2 / 5) * 5; + } + } + // If no lens selected, keep current focal length + return { ...prev, lensId: newLensId || undefined, - // Set default focal length for prime lenses - focalLength: lens?.focalLength || prev.focalLength + focalLength: newFocalLength }; }); }} @@ -682,33 +694,94 @@ getUserMedia: ${!!navigator.mediaDevices?.getUserMedia} {/* Focal Length Slider */} - - Focal Length: {currentSettings.focalLength || 'Not set'}mm - - setCurrentSettings(prev => ({ ...prev, focalLength: value as number }))} - min={1} - max={200} - step={1} - marks={[ - { value: 1, label: '1mm' }, - { value: 50, label: '50mm' }, - { value: 100, label: '100mm' }, - { value: 200, label: '200mm' } - ]} - valueLabelDisplay="auto" - /> - setCurrentSettings(prev => ({ ...prev, focalLength: parseInt(e.target.value) || undefined }))} - inputProps={{ min: 1, max: 10000 }} - size="small" - sx={{ mt: 1 }} - /> + {(() => { + const selectedLens = lenses.find(l => l.id === currentSettings.lensId); + const isPrime = selectedLens && selectedLens.focalLength !== undefined; + const isZoom = selectedLens && selectedLens.focalLengthMin !== undefined && selectedLens.focalLengthMax !== undefined; + + let sliderMin = 1; + let sliderMax = 200; + let sliderStep = 1; + let sliderDisabled = false; + let currentValue = currentSettings.focalLength || 50; + + if (isPrime) { + // Prime lens: set to fixed focal length and disable + currentValue = selectedLens.focalLength!; + sliderDisabled = true; + } else if (isZoom) { + // Zoom lens: constrain to lens range with step=5 + sliderMin = selectedLens.focalLengthMin!; + sliderMax = selectedLens.focalLengthMax!; + sliderStep = 5; + + // Ensure current value is within range and snapped to step + if (currentValue < sliderMin) currentValue = sliderMin; + if (currentValue > sliderMax) currentValue = sliderMax; + // Snap to nearest step + currentValue = Math.round(currentValue / sliderStep) * sliderStep; + } + // No lens selected: use default values (already set above) + + return ( + <> + + Focal Length: {currentValue || 'Not set'}mm + {isPrime && ' (Prime lens - fixed)'} + {isZoom && ` (${sliderMin}-${sliderMax}mm zoom)`} + + setCurrentSettings(prev => ({ ...prev, focalLength: value as number }))} + min={sliderMin} + max={sliderMax} + step={sliderStep} + marks={[ + { value: sliderMin, label: `${sliderMin}mm` }, + ...(sliderMin < 50 && sliderMax > 50 ? [{ value: 50, label: '50mm' }] : []), + ...(sliderMin < 100 && sliderMax > 100 ? [{ value: 100, label: '100mm' }] : []), + { value: sliderMax, label: `${sliderMax}mm` } + ]} + valueLabelDisplay="auto" + disabled={sliderDisabled} + sx={{ + ...(sliderDisabled && { + '& .MuiSlider-thumb': { + backgroundColor: 'grey.500' + }, + '& .MuiSlider-track': { + backgroundColor: 'grey.500' + } + }) + }} + /> + { + const value = parseInt(e.target.value); + if (isZoom && value) { + // Constrain to zoom range + const constrained = Math.max(sliderMin, Math.min(sliderMax, value)); + setCurrentSettings(prev => ({ ...prev, focalLength: constrained })); + } else if (!isPrime) { + // Allow any value if not a prime lens + setCurrentSettings(prev => ({ ...prev, focalLength: value || undefined })); + } + }} + inputProps={{ + min: isZoom ? sliderMin : 1, + max: isZoom ? sliderMax : 10000 + }} + size="small" + sx={{ mt: 1 }} + disabled={isPrime} + /> + + ); + })()} {/* Additional Info */} diff --git a/src/components/DetailsScreen.tsx b/src/components/DetailsScreen.tsx index d38f419..e429453 100644 --- a/src/components/DetailsScreen.tsx +++ b/src/components/DetailsScreen.tsx @@ -207,7 +207,30 @@ export const DetailsScreen: React.FC = ({ Lens +``` + +## 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 +