diff --git a/App.tsx b/App.tsx index b8b2a97..9efd735 100644 --- a/App.tsx +++ b/App.tsx @@ -1,9 +1,10 @@ -import React, { useState, useEffect } from 'react'; +import React, { useState, useEffect, useRef, useCallback } from 'react'; import { View, StatusBar, Alert, ActivityIndicator, Text, AppState, Platform } from 'react-native'; import { useCameraPermission, usePhotoOutput } from 'react-native-vision-camera'; import * as ImagePicker from 'expo-image-picker'; import * as FileSystem from 'expo-file-system/legacy'; import * as MediaLibrary from 'expo-media-library'; +import * as MediaLibraryLegacy from 'expo-media-library/legacy'; import * as Haptics from 'expo-haptics'; import AsyncStorage from '@react-native-async-storage/async-storage'; @@ -14,7 +15,7 @@ import CameraControls from './src/components/CameraControls'; import ToolRail, { TabId } from './src/components/ToolRail'; import PreviewModal from './src/components/PreviewModal'; -import { Recipe, ColorAdjustments, FrameId, GPSInfo } from './src/types'; +import { Recipe, ColorAdjustments, FrameId, GPSInfo, AspectRatio } from './src/types'; import { getAllRecipes, saveCustomRecipe, deleteCustomRecipe } from './src/utils/storageUtils'; import { DEFAULT_ADJUSTMENTS } from './src/utils/defaultRecipes'; import { getCurrentGPS, requestLocationPermissions } from './src/utils/locationUtils'; @@ -37,6 +38,10 @@ export default function App() { const [selectedFrame, setSelectedFrame] = useState('none'); const [useGeotag, setUseGeotag] = useState(true); const [gpsInfo, setGpsInfo] = useState(null); + // Composition aspect-ratio (FULL / 4:3 / 3:2 upright) + RAW DNG sidecar. + const [aspectRatio, setAspectRatio] = useState('full'); + const [rawEnabled, setRawEnabled] = useState(false); + const rawFailRef = useRef(false); // Progressive disclosure: a tool chip opens its panel; null = photo hero only. const [openTab, setOpenTab] = useState(null); @@ -53,6 +58,15 @@ export default function App() { const photoOutput = usePhotoOutput({ qualityPrioritization: 'quality', }); + // Second photo output for RAW DNG sidecars (RATIO panel → RAW on). Kept as + // a live output only while rawEnabled — CameraX cannot bind two ImageCapture + // use cases, so attaching both at once would break the session. Attaching a + // DNG output while RAW is on gives ~one exposure per capture: the JPEG below + // and this DNG are separate shots taken a few tens of ms apart. + const rawPhotoOutput = usePhotoOutput({ + containerFormat: 'dng', + qualityPrioritization: 'quality', + }); // vision-camera may return a raw absolute path on Android; surface a file:// URI. const toUri = (path: string) => (/^(file|content|ph):\/\//.test(path) ? path : `file://${path}`); @@ -74,6 +88,7 @@ export default function App() { adjustments, selectedFrame, useGeotag, + aspectRatio, selectedRecipeId: selectedRecipe?.id || null, gpsInfo, }; @@ -85,7 +100,7 @@ export default function App() { } }); return () => subscription.remove(); - }, [mode, libraryImageUri, adjustments, selectedFrame, useGeotag, selectedRecipe, gpsInfo]); + }, [mode, libraryImageUri, adjustments, selectedFrame, useGeotag, selectedRecipe, gpsInfo, aspectRatio]); const initSession = async () => { const list = await getAllRecipes(); @@ -121,6 +136,7 @@ export default function App() { setAdjustments({ ...DEFAULT_ADJUSTMENTS, ...session.adjustments }); if (session.selectedFrame) setSelectedFrame(session.selectedFrame); if (session.useGeotag !== undefined) setUseGeotag(session.useGeotag); + if (session.aspectRatio === '4:3' || session.aspectRatio === '3:2') setAspectRatio(session.aspectRatio); if (session.gpsInfo) setGpsInfo(session.gpsInfo); if (session.selectedRecipeId) { restoredRecipe = list.find((r: Recipe) => r.id === session.selectedRecipeId) || null; @@ -189,6 +205,66 @@ export default function App() { } }; + const handleAspectRatio = (ratio: AspectRatio) => { + setAspectRatio(ratio); + if (openTab === 'ratio') { + Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); + } + }; + + const handleRawEnabled = (enabled: boolean) => { + rawFailRef.current = false; // allow one recovery alert per enable + setRawEnabled(enabled); + if (enabled) { + Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium); + } + }; + + // Latest-rawEnabled ref so the stable session-error callback below can read + // the live toggle without re-subscribing the Camera session on every render. + const rawEnabledRef = useRef(rawEnabled); + rawEnabledRef.current = rawEnabled; + // Attaching the DNG output while RAW is on adds a second ImageCapture use + // case — CameraX historically refuses two per camera. If the session errors + // in that state, drop RAW (removing the output reconfigures back to the + // single working JPEG session) instead of leaving a dead camera. + const handleCameraSessionError = useCallback((error: Error) => { + console.error('[camera-session] error:', error); + if (rawEnabledRef.current && !rawFailRef.current) { + rawFailRef.current = true; + setRawEnabled(false); + Alert.alert( + 'RAW Unavailable', + 'DNG capture could not start on this device, so RAW was turned off. JPEG capture keeps working.' + ); + } + }, []); + + // Save the freshly captured DNG into an album literally named "RAW" (create + // the album on first use). Failure never blocks the JPEG pipeline. + const saveRawToAlbum = async (uri: string): Promise => { + try { + const perm = await MediaLibraryLegacy.requestPermissionsAsync(); + if (!perm.granted) { + console.warn('RAW save: media library permission denied'); + return false; + } + const asset = await MediaLibraryLegacy.createAssetAsync(uri); + const albumName = 'RAW'; + let album = await MediaLibraryLegacy.getAlbumAsync(albumName); + if (album) { + await MediaLibraryLegacy.addAssetsToAlbumAsync(asset, album); + } else { + await MediaLibraryLegacy.createAlbumAsync(albumName, asset); + } + console.error(`[raw] saved DNG to album ${albumName}`); + return true; + } catch (e) { + console.error('RAW album save failed:', e); + return false; + } + }; + const handleSaveRecipe = async (name: string) => { if (!selectedRecipe) return; const newRecipe = await saveCustomRecipe({ @@ -318,6 +394,25 @@ export default function App() { console.error(`[capture] shot-took ${Date.now() - tShot}ms`); const sourceUri = toUri(filePath); + // RAW DNG sidecar: fire the second exposure immediately after the JPEG + // so the two shots stay a few tens of ms apart, then file it into the + // "RAW" album without blocking the JPEG export below. + if (rawEnabled && rawPhotoOutput) { + try { + const tRaw = Date.now(); + const rawFile = await rawPhotoOutput.capturePhotoToFile({}, {}); + console.error(`[capture] raw-shot-took ${Date.now() - tRaw}ms`); + const rawUri = toUri(rawFile.filePath); + saveRawToAlbum(rawUri).then((ok) => { + if (!ok) { + Alert.alert('RAW Not Saved', 'The DNG was captured but could not be added to the RAW album.'); + } + }); + } catch (rawError) { + console.error('RAW capture failed:', rawError); + } + } + const tExp = Date.now(); const result = await processAndExportPhoto( sourceUri, @@ -327,7 +422,13 @@ export default function App() { captureGps, // 300dpi header + full-res screen sharpen. evFromCamera: the AE bias // already applied exposure compensation at capture — don't add 2^EV. - { dpi: 300, sharpen: true, evFromCamera: true } + // aspect: crop to the on-screen composition ratio (full = keep 4:3 native). + { + dpi: 300, + sharpen: true, + evFromCamera: true, + aspect: aspectRatio === 'full' ? undefined : aspectRatio, + } ); console.error(`[capture] export-took ${Date.now() - tExp}ms (pipeline total ${Date.now() - tShot}ms)`); @@ -367,7 +468,9 @@ export default function App() { }, selectedFrame, useGeotag, - gpsInfo + gpsInfo, + // Library export follows the same composition ratio as the viewfinder band. + { aspect: aspectRatio === 'full' ? undefined : aspectRatio } ); setIsProcessing(false); @@ -416,6 +519,10 @@ export default function App() { cameraPermissionGranted={cameraPermissionGranted} onRequestCameraPermission={requestCameraPermission} photoOutput={photoOutput} + rawOutput={rawPhotoOutput} + rawEnabled={rawEnabled} + aspectRatio={aspectRatio} + onSessionError={handleCameraSessionError} onExposureChange={(ev) => handleUpdateAdjustments({ exposureCompensation: ev })} /> @@ -441,6 +548,11 @@ export default function App() { adjustments={adjustments} selectedFrame={selectedFrame} useGeotag={useGeotag} + aspectRatio={aspectRatio} + rawEnabled={rawEnabled} + cameraMode={mode === 'camera'} + onAspectRatio={handleAspectRatio} + onRawEnabled={handleRawEnabled} onSelectRecipe={applyRecipe} onUpdateAdjustments={handleUpdateAdjustments} onUpdateFrame={handleUpdateFrame} diff --git a/src/components/AdjustmentPanel.tsx b/src/components/AdjustmentPanel.tsx index 5012dc1..8d3ea76 100644 --- a/src/components/AdjustmentPanel.tsx +++ b/src/components/AdjustmentPanel.tsx @@ -3,7 +3,7 @@ import { View, Text, TouchableOpacity, ScrollView } from 'react-native'; import Slider from '@react-native-community/slider'; import * as Haptics from 'expo-haptics'; import { Trash2 } from 'lucide-react-native'; -import { Recipe, ColorAdjustments, FrameId } from '../types'; +import { Recipe, ColorAdjustments, FrameId, AspectRatio } from '../types'; import { FRAMES } from '../utils/frameUtils'; // Compact bottom sheet above the tool rail: @@ -12,7 +12,7 @@ import { FRAMES } from '../utils/frameUtils'; // Tapping a parameter chip toggles its slider row; chips stay visible. // Darkroom-flavoured: amber = active/edited, red = destructive, mono labels. -type TabId = 'recipes' | 'iq' | 'wb' | 'filters' | 'frame'; +type TabId = 'recipes' | 'iq' | 'wb' | 'filters' | 'frame' | 'ratio'; // Choice-group keys that open a strip above the chip row: color chrome groups // (wb tab) and the D.RANGE selector (iq tab). type GroupKey = 'cx' | 'cxb' | 'dr'; @@ -24,10 +24,16 @@ interface AdjustmentPanelProps { adjustments: ColorAdjustments; selectedFrame: FrameId; useGeotag: boolean; + aspectRatio: AspectRatio; + rawEnabled: boolean; + // RAW (DNG sidecar) is only meaningful while the live camera is the source. + cameraMode: boolean; onSelectRecipe: (recipe: Recipe) => void; onUpdateAdjustments: (adjustments: Partial) => void; onUpdateFrame: (frameId: FrameId) => void; onToggleGeotag: (enabled: boolean) => void; + onAspectRatio: (ratio: AspectRatio) => void; + onRawEnabled: (enabled: boolean) => void; onSaveRecipe: (name: string) => void; onDeleteRecipe: (id: string) => void; } @@ -66,10 +72,15 @@ export default function AdjustmentPanel({ adjustments, selectedFrame, useGeotag, + aspectRatio, + rawEnabled, + cameraMode, onSelectRecipe, onUpdateAdjustments, onUpdateFrame, onToggleGeotag, + onAspectRatio, + onRawEnabled, onDeleteRecipe, }: AdjustmentPanelProps) { // The single parameter whose slider row is open — a new selection replaces @@ -188,6 +199,7 @@ export default function AdjustmentPanel({ { key: 'grain', label: 'MONOCHROME GRAIN', value: a.grain, default: 0, min: 0, max: 10, step: 1, display: String, onChange: (v) => onUpdateAdjustments({ grain: v }) }, ], frame: [], + ratio: [], }; const wbPresetOf = (): string => { @@ -360,6 +372,36 @@ export default function AdjustmentPanel({ ); + case 'ratio': + // Composition aspect (FULL = current fullscreen, 4:3 / 3:2 are the + // upright photo ratios) + RAW DNG sidecar toggle (camera captures + // only — dimmed in library mode where the shutter exports a still). + return ( + + {renderChip({ + key: 'raw', + label: rawEnabled ? 'RAW ON' : 'RAW OFF', + active: rawEnabled, + disabled: !cameraMode, + onPress: () => onRawEnabled(!rawEnabled), + })} + {choiceChips( + 'ratio', + [ + { v: 'full', d: 'FULL' }, + { v: '4:3', d: '4:3' }, + { v: '3:2', d: '3:2' }, + ], + aspectRatio, + (v) => onAspectRatio(v as AspectRatio) + )} + + ); + default: return null; } diff --git a/src/components/ToolRail.tsx b/src/components/ToolRail.tsx index 0997ce8..0ea326f 100644 --- a/src/components/ToolRail.tsx +++ b/src/components/ToolRail.tsx @@ -1,9 +1,9 @@ import React from 'react'; import { View, Text, TouchableOpacity, ScrollView } from 'react-native'; -import { LayoutGrid, Sun, Thermometer, Sparkles, Frame, type LucideIcon } from 'lucide-react-native'; +import { LayoutGrid, Sun, Thermometer, Sparkles, Frame, Ratio, type LucideIcon } from 'lucide-react-native'; import * as Haptics from 'expo-haptics'; -export type TabId = 'recipes' | 'iq' | 'wb' | 'filters' | 'frame'; +export type TabId = 'recipes' | 'iq' | 'wb' | 'filters' | 'frame' | 'ratio'; interface ToolRailProps { activeTab: TabId | null; @@ -16,6 +16,7 @@ const TOOLS: { id: TabId; label: string; icon: LucideIcon }[] = [ { id: 'wb', label: 'WB', icon: Thermometer }, { id: 'filters', label: 'FX', icon: Sparkles }, { id: 'frame', label: 'FRAME', icon: Frame }, + { id: 'ratio', label: 'RATIO', icon: Ratio }, ]; export default function ToolRail({ activeTab, onToggle }: ToolRailProps) { diff --git a/src/components/Viewfinder.tsx b/src/components/Viewfinder.tsx index 5024a26..cdd2a43 100644 --- a/src/components/Viewfinder.tsx +++ b/src/components/Viewfinder.tsx @@ -22,7 +22,7 @@ import { MipmapMode, } from '@shopify/react-native-skia'; import { Lock } from 'lucide-react-native'; -import { Recipe, GPSInfo, FrameId } from '../types'; +import { Recipe, GPSInfo, FrameId, AspectRatio, ASPECT_RATIO_W_H } from '../types'; import { getSkiaColorMatrix, applyExposureGain } from '../utils/colorUtils'; import { TONE_SKSL, getToneUniforms } from '../utils/toneShader'; import { CINEMA_SKSL, getCinemaUniforms, cinemaOffFlat } from '../utils/cinemaShader'; @@ -38,6 +38,17 @@ interface ViewfinderProps { cameraPermissionGranted: boolean; onRequestCameraPermission: () => void; photoOutput: CameraPhotoOutput; + // Composition ratio (FULL / 4:3 / 3:2 upright) driving the viewfinder band + // and the photo-area crop. Wall frame ignores it (fixed frame opening). + aspectRatio: AspectRatio; + // RAW DNG toggle: while on, the DNG output below is attached to the session + // (a second ImageCapture use case). Kept null-free at the App level; this is + // nullable only because the session may not exist yet. + rawEnabled: boolean; + rawOutput: CameraPhotoOutput | null; + // Session-level error from the camera (e.g. DNG output failed to bind). App + // auto-disables RAW on such errors and alerts the user. + onSessionError?: (error: Error) => void; // Quick exposure slider (shown while AE/AF is locked) — live AE bias in EV. onExposureChange?: (ev: number) => void; } @@ -89,6 +100,10 @@ export default function Viewfinder({ cameraPermissionGranted, onRequestCameraPermission, photoOutput, + aspectRatio, + rawEnabled, + rawOutput, + onSessionError, onExposureChange, }: ViewfinderProps) { // Fullscreen hero: the viewfinder fills the entire screen, every overlay @@ -96,6 +111,35 @@ export default function Viewfinder({ const { width: vw, height: vh } = useWindowDimensions(); const insets = useSafeAreaInsets(); + // Composition ratio band. With a ratio active the photo area is a centered + // portrait strip (w/h = ratioWH) and everything outside is letterboxed; + // exports crop to the same ratio. Only the plain full-picture frames use the + // band — polaroid keeps its own card layout (window aspect follows the ratio) + // and wall frame deliberately ignores the ratio entirely (its opening is a + // fixed 2:3). When no band is active the region is just the full screen, so + // every consumer below degrades to the old fullscreen behavior untouched. + const ratioWH = aspectRatio === 'full' ? null : ASPECT_RATIO_W_H[aspectRatio]; + const regionBand = + ratioWH !== null && + (selectedFrame === 'none' || selectedFrame === 'classic-white' || selectedFrame === 'cinematic'); + const region = useMemo(() => { + if (!regionBand || ratioWH === null) return { x: 0, y: 0, w: vw, h: vh }; + const h = vw / ratioWH; + if (h <= vh) { + return { x: 0, y: (vh - h) / 2, w: vw, h }; + } + // Screen narrower than the ratio: fit by height, letterbox the sides. + const w = vh * ratioWH; + return { x: (vw - w) / 2, y: 0, w, h }; + }, [regionBand, ratioWH, vw, vh]); + // Camera outputs: attach the DNG output only while RAW is on — CameraX + // cannot bind two ImageCapture use cases at once, and App's session-error + // handler turns RAW off again when that ever fails. + const cameraOutputs = useMemo( + () => (rawEnabled && rawOutput ? [photoOutput, rawOutput] : [photoOutput]), + [photoOutput, rawOutput, rawEnabled] + ); + // useCameraDevice resolves the back camera fresh on every render, so once the // permission lands (first-run flow: GRANT button → OS dialog) a re-render is // enough to pick the device up — no manual polling needed. @@ -397,21 +441,27 @@ export default function Viewfinder({ const dh = h * s; return { dx: (vw - dw) / 2, dy: (vh - dh) / 2, dw, dh }; }, [skiaImage, vw, vh]); - // Polaroid card geometry (JS/overlay space) for the CURRENT mode. Library - // stills keep the photo aspect; the camera assumes the upright capture - // aspect of the photo output (4:3 sensor → 3:4 portrait), NOT the screen + // Polaroid print geometry (JS/overlay space) for the CURRENT mode. The card + // always fills/holds to the screen container; the photo WINDOW keeps the + // *composition* aspect: with a ratio active that ratio (window content is + // center-cropped to it), otherwise the library photo's own aspect or the + // camera's upright capture aspect (4:3 sensor → 3:4 portrait, NOT the screen // aspect: a screen-aspect window made the card nearly fullscreen and // pole-like, whereas the real print is a shorter 3:4 card letterboxed on - // screen. ponytail: verified on Xiaomi quality-mode captures (4:3); if the + // screen). ponytail: verified on Xiaomi quality-mode captures (4:3); if the // session negotiates 16:9 photos, change to 9/16. + const polaroidAspect = + ratioWH ?? + (mode === 'library' && skiaImage && skiaImage.width() > 0 && skiaImage.height() > 0 + ? skiaImage.width() / skiaImage.height() + : 3 / 4); + // Photo draw fit inside the polaroid window: cover when the window aspect + // (ratio) differs from the source photo, fill otherwise (aspects match). + const polaroidFit: 'cover' | 'fill' = ratioWH ? 'cover' : 'fill'; const polaroidGeo = useMemo(() => { if (selectedFrame !== 'polaroid') return null; - const aspect = - mode === 'library' && skiaImage && skiaImage.width() > 0 && skiaImage.height() > 0 - ? skiaImage.width() / skiaImage.height() - : 3 / 4; - return polaroidLayout(vw, vh, aspect, 'contain'); - }, [selectedFrame, mode, skiaImage, vw, vh]); + return polaroidLayout(vw, vh, polaroidAspect, 'contain'); + }, [selectedFrame, polaroidAspect, vw, vh]); // Wall-frame PNG geometry (JS/overlay space). Fixed landscape 3:2 window — // the PNG is contain-fitted like a physical frame; photo content behind it // is cover-cropped by the mat opening (never letterboxed inside the window). @@ -479,11 +529,14 @@ export default function Viewfinder({ lastTapRef.current = null; setLibZoom((z) => { if (z.s > 1.01) return { s: 1, tx: 0, ty: 0 }; // double-tap again -> 100% - // Clamp the focal point into the photo's contain rect so a double-tap + // Clamp the focal point into the photo's display rect (composition + // region under a ratio band, contain rect otherwise) so a double-tap // that lands in the black letterbox cannot fling the photo away. let fx = x; let fy = y; - const fit = imageFitRect; + const fit = regionBand + ? { dx: region.x, dy: region.y, dw: region.w, dh: region.h } + : imageFitRect; if (fit) { fx = Math.min(Math.max(fx, fit.dx), fit.dx + fit.dw); fy = Math.min(Math.max(fy, fit.dy), fit.dy + fit.dh); @@ -506,8 +559,10 @@ export default function Viewfinder({ [libZoom] ); - const borderWidth = Math.min(vw, vh) * 0.05; - const barHeight = vh * 0.12; + // Border/bars scale with the composition region so framed exports stay + // proportional to the cropped photo (region == fullscreen when no ratio). + const borderWidth = Math.min(region.w, region.h) * 0.05; + const barHeight = region.h * 0.12; const renderFrameOverlay = () => { if (selectedFrame === 'none') return null; @@ -576,21 +631,24 @@ export default function Viewfinder({ } if (selectedFrame === 'classic-white') { + // Border drawn on the composition region (region == fullscreen when no + // ratio band), so it always sits at the photo's edge. return ( - - - - + + + + ); } if (selectedFrame === 'cinematic') { + // Letterbox bars hug the composition region's top/bottom edges. return ( - - + + ); } @@ -617,9 +675,11 @@ export default function Viewfinder({ const win = polaroidGeo?.window ?? wallGeo?.window ?? null; const area = win ? { dx: win.x, dy: win.y, dw: win.w, dh: win.h } - : mode === 'library' && imageFitRect - ? imageFitRect - : { dx: 0, dy: 0, dw: vw, dh: vh }; + : regionBand + ? { dx: region.x, dy: region.y, dw: region.w, dh: region.h } + : mode === 'library' && imageFitRect + ? imageFitRect + : { dx: 0, dy: 0, dw: vw, dh: vh }; const iconSize = Math.round(vw * 0.032); // matches customFont/iconFont size const topBarBottom = insets.top + 56; const topBase = Math.max(area.dy + Math.round(iconSize * 0.6), topBarBottom); @@ -649,15 +709,26 @@ export default function Viewfinder({ return ( <> + {/* Aspect-ratio band: black out the live feed outside the composition + region. Frames with their own geometry (polaroid/wall) draw their + own bars later, so this only fires for the plain frames. */} + {regionBand && ( + + + + + + + )} {grainOpacity > 0 && noiseEffect && ( {/* Grain belongs to the photo; on a framed photo it must not spill onto the mat, so it is clipped to the photo window. */} @@ -668,13 +739,17 @@ export default function Viewfinder({ {renderGPSWatermark()} - - - - - - - + {/* Rule-of-thirds guide, clipped to the composition region (fullscreen + when no ratio band). */} + + + + + ); @@ -715,10 +790,11 @@ export default function Viewfinder({ style={StyleSheet.absoluteFill} device={device} isActive={appActive} - outputs={[photoOutput]} + outputs={cameraOutputs} pixelFormat="yuv" onStarted={() => setCameraActive(true)} onFrame={handleFrame} + onError={onSessionError} /> {/* Tap-to-focus layer: fullscreen responder (all visual overlays above are pointerEvents none). locationX/Y are relative to this view. */} @@ -793,6 +869,14 @@ export default function Viewfinder({ const toneOn = toneParams[0] !== 0 || toneParams[1] !== 0 || toneParams[2] !== 0; const cinemaOn = cinemaEffect != null && !!cinemaParams; const useShaderPass = !!imageFitRect && ((toneEffect != null && toneOn) || cinemaOn); + // Photo display rect + draw fit in the plain library viewer: with a ratio + // band the photo is center-cropped (cover) into the composition region; + // otherwise the full photo is contain-fitted to the screen (fill into its + // own aspect rect — no distortion, identical to the old fullscreen contain). + const dispRect = regionBand + ? { dx: region.x, dy: region.y, dw: region.w, dh: region.h } + : imageFitRect; + const dispFit: 'cover' | 'fill' = regionBand ? 'cover' : 'fill'; // RETRO POLAROID (library): the whole picture is contain-fitted into the // card's photo window — nothing is masked or cropped, and the card is @@ -817,10 +901,10 @@ export default function Viewfinder({ source={toneEffect} uniforms={{ dr: toneParams[0], hl: toneParams[1], sh: toneParams[2] }} > - + ) : ( - + )} ) : toneEffect && toneOn ? ( @@ -828,12 +912,12 @@ export default function Viewfinder({ source={toneEffect} uniforms={{ dr: toneParams[0], hl: toneParams[1], sh: toneParams[2] }} > - + ) : null} ) : ( - + )} {grainOpacity > 0 && noiseEffect && ( @@ -937,10 +1021,10 @@ export default function Viewfinder({ // copy of the photo"). Cinema samples the tone output when both // are on (chain: cinema -> tone -> image), matrix applies after. {cinemaOn ? ( @@ -951,19 +1035,19 @@ export default function Viewfinder({ > ) : ( )} @@ -974,21 +1058,29 @@ export default function Viewfinder({ > ) : null} + ) : dispRect ? ( + ) : ( )} {grainOpacity > 0 && noiseEffect && ( - + {/* Grain covers the whole photo area — the composition region + under a ratio band, the full screen otherwise. */} + diff --git a/src/types/index.ts b/src/types/index.ts index db06e35..d5652bc 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -1,5 +1,16 @@ export type FrameId = 'none' | 'polaroid' | 'classic-white' | 'cinematic' | 'wallframe'; +// Viewfinder/composition aspect-ratio selector. Convention is an UPRIGHT +// (portrait) picture: "4:3" = standing 3:4 photo (w/h = 3/4), "3:2" = standing +// 2:3 photo (w/h = 2/3). "full" = current fullscreen behaviour (no ratio crop). +// Wall Frame ignores the ratio (frame + export keep fullscreen as before). +export type AspectRatio = 'full' | '4:3' | '3:2'; +// w/h of the final upright photo for each non-full choice. +export const ASPECT_RATIO_W_H: Record, number> = { + '4:3': 3 / 4, + '3:2': 2 / 3, +}; + // Cinematic seasonal atmosphere grade. Not a fake re-light: it reads the // photo's EXISTING exposure — warm tint + soft glow on areas already lit, // cool tint in shade, plus a seasonal atmospheric haze/fog veil. diff --git a/src/utils/exportEngine.ts b/src/utils/exportEngine.ts index d9db869..8221835 100644 --- a/src/utils/exportEngine.ts +++ b/src/utils/exportEngine.ts @@ -3,7 +3,7 @@ import { File } from 'expo-file-system'; import * as FileSystem from 'expo-file-system/legacy'; import * as MediaLibrary from 'expo-media-library/legacy'; import { Asset } from 'expo-asset'; -import { Recipe, GPSInfo, FrameId } from '../types'; +import { Recipe, GPSInfo, FrameId, AspectRatio, ASPECT_RATIO_W_H } from '../types'; import { getSkiaColorMatrix, applyExposureGain } from './colorUtils'; import { TONE_SKSL, getToneUniforms, toneIsActive } from './toneShader'; import { CINEMA_SKSL, getCinemaUniforms, cinemaIsActive } from './cinemaShader'; @@ -25,6 +25,12 @@ export interface ExportOptions { // already applied as a hardware AE bias at capture, so no 2^EV matrix gain // should be added on top. Library stills omit this and get the matrix gain. evFromCamera?: boolean; + // Composition aspect-ratio crop (upright 4:3 or 3:2). Center-largest crop + // applied right after decode — every later step (frame/GPS/sharpen/size) + // then runs on the cropped pixels, so the export matches the ratio the + // viewfinder displayed. WALL FRAME ignores this (frame keeps its fixed + // opening and export size). 'full'/undefined = no crop. + aspect?: AspectRatio; } // Lightroom-style "Screen" output sharpen: subtle unsharp mask. A 3x3 @@ -87,18 +93,60 @@ export async function processAndExportPhoto( console.error('Failed to load image data from URI'); return null; } - const skImage = Skia.Image.MakeImageFromEncoded(skiaData); + const skImage0 = Skia.Image.MakeImageFromEncoded(skiaData); - if (!skImage) { + if (!skImage0) { console.error('Failed to parse image from URI'); return null; } console.error(`[export] decode ${Date.now() - t0}ms`); - const width = skImage.width(); - const height = skImage.height(); + let skImage = skImage0; + let width = skImage.width(); + let height = skImage.height(); console.error(`[export] BEGIN ${width}x${height} ev=${recipe.adjustments.exposureCompensation ?? 0} evFromCamera=${options?.evFromCamera ?? false}`); + // 1b. Aspect-ratio crop (composition): center-largest upright crop of the + // requested ratio. Runs BEFORE any processing so frame/GPS/grain/sharpen + // all apply to exactly the pixels the user framed. Wall Frame is exempt — + // its export is the fixed frame artwork whose opening cover-crops anyway. + const ratioWH = options?.aspect && options.aspect !== 'full' ? ASPECT_RATIO_W_H[options.aspect] : null; + if (ratioWH && frameId !== 'wallframe') { + const curAspect = width / height; + let cropW = width; + let cropH = height; + let srcX = 0; + let srcY = 0; + if (curAspect > ratioWH) { + cropW = Math.round(height * ratioWH); + srcX = Math.round((width - cropW) / 2); + } else { + cropH = Math.round(width / ratioWH); + srcY = Math.round((height - cropH) / 2); + } + const cropSurface = createSurface(cropW, cropH); + if (!cropSurface) { + console.error('Failed to create aspect-crop surface'); + return null; + } + const cropCanvas = cropSurface.getCanvas(); + cropCanvas.drawImageRect( + skImage, + Skia.XYWHRect(srcX, srcY, cropW, cropH), + Skia.XYWHRect(0, 0, cropW, cropH), + Skia.Paint() + ); + const cropped = cropSurface.makeImageSnapshot(); + if (!cropped) { + console.error('Failed to snapshot aspect crop'); + return null; + } + skImage = cropped; + width = cropW; + height = cropH; + console.error(`[export] aspect-crop ${width}x${height}`); + } + // 2. Create offscreen canvas (GPU-backed when available — see createSurface) const surface = createSurface(width, height); if (!surface) {