fix(camera): matrix-based zoom for pinch/double-tap, tap-to-focus AE/AF lock, library letterbox smear fix, export raw JPEG bytes q95, resume isActive fixes, dark theme polish

This commit is contained in:
2026-09-08 10:25:34 +07:00
parent 515f408c67
commit 8eca31ce58
25 changed files with 1450 additions and 822 deletions
+70 -61
View File
@@ -1,21 +1,21 @@
import React, { useState, useEffect, useRef } from 'react';
import React, { useState, useEffect } from 'react';
import { View, StatusBar, Alert, ActivityIndicator, Text, AppState } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { useCameraPermission, type Camera } from 'react-native-vision-camera';
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 Haptics from 'expo-haptics';
import AsyncStorage from '@react-native-async-storage/async-storage';
import Header from './src/components/Header';
import TopBar from './src/components/TopBar';
import Viewfinder from './src/components/Viewfinder';
import TabSelector, { TabId } from './src/components/TabSelector';
import AdjustmentPanel from './src/components/AdjustmentPanel';
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 { getAllRecipes, saveCustomRecipe, deleteCustomRecipe } from './src/utils/storageUtils';
import { DEFAULT_ADJUSTMENTS } from './src/utils/defaultRecipes';
import { getCurrentGPS, requestLocationPermissions } from './src/utils/locationUtils';
import { resolvePhotoGPS } from './src/utils/photoGps';
import { processAndExportPhoto } from './src/utils/exportEngine';
@@ -31,24 +31,13 @@ export default function App() {
const [selectedRecipe, setSelectedRecipe] = useState<Recipe | null>(null);
// Custom temporary overrides for the sliders
const [adjustments, setAdjustments] = useState<ColorAdjustments>({
exposure: 0,
contrast: 0,
saturation: 0,
temperature: 5500,
tint: 0,
highlight: 0,
shadow: 0,
denoise: 0,
clarity: 0,
grain: 0,
colorChrome: 'none',
});
const [adjustments, setAdjustments] = useState<ColorAdjustments>({ ...DEFAULT_ADJUSTMENTS });
const [selectedFrame, setSelectedFrame] = useState<FrameId>('none');
const [useGeotag, setUseGeotag] = useState(true);
const [gpsInfo, setGpsInfo] = useState<GPSInfo | null>(null);
const [activeTab, setActiveTab] = useState<TabId>('recipes');
// Progressive disclosure: a tool chip opens its panel; null = photo hero only.
const [openTab, setOpenTab] = useState<TabId | null>(null);
const [libraryImageUri, setLibraryImageUri] = useState<string | null>(null);
const [lastPhotoUri, setLastPhotoUri] = useState<string | null>(null);
@@ -58,7 +47,11 @@ export default function App() {
// Hardware Permissions (vision-camera)
const { hasPermission: cameraPermissionGranted, requestPermission: requestCameraPermission } = useCameraPermission();
const cameraRef = useRef<Camera>(null);
// Full-resolution photo output — attached to the live <Camera> in Viewfinder.
// Photo capture is resolution-independent from the Skia preview output.
const photoOutput = usePhotoOutput({
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}`);
@@ -123,7 +116,8 @@ export default function App() {
} else {
setMode(session.mode || 'camera');
}
if (session.adjustments) setAdjustments(session.adjustments);
if (session.adjustments)
setAdjustments({ ...DEFAULT_ADJUSTMENTS, ...session.adjustments });
if (session.selectedFrame) setSelectedFrame(session.selectedFrame);
if (session.useGeotag !== undefined) setUseGeotag(session.useGeotag);
if (session.gpsInfo) setGpsInfo(session.gpsInfo);
@@ -267,7 +261,7 @@ export default function App() {
Alert.alert('Permission Needed', 'Grant camera permission to take photos.');
return;
}
if (!cameraRef.current) {
if (!photoOutput) {
Alert.alert('Camera Not Ready', 'Please wait for the camera to start, then try again.');
return;
}
@@ -285,17 +279,23 @@ export default function App() {
}
}
const photo = await cameraRef.current.takePhoto({});
const sourceUri = toUri(photo.path);
const tShot = Date.now();
const { filePath } = await photoOutput.capturePhotoToFile({}, {});
console.error(`[capture] shot-took ${Date.now() - tShot}ms`);
const sourceUri = toUri(filePath);
const tExp = Date.now();
const result = await processAndExportPhoto(
sourceUri,
{ ...activeRecipe, adjustments, frameId: selectedFrame, useGeotag },
selectedFrame,
useGeotag,
captureGps,
{ dpi: 300, sharpen: true } // 300dpi header + full-res screen sharpen
// 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 }
);
console.error(`[capture] export-took ${Date.now() - tExp}ms (pipeline total ${Date.now() - tShot}ms)`);
if (result) {
setLastPhotoUri(result.uri);
@@ -358,14 +358,11 @@ export default function App() {
};
return (
<SafeAreaView className="flex-1 bg-carbon">
<StatusBar barStyle="light-content" backgroundColor="#08080a" />
{/* Top Header */}
<Header mode={mode} setMode={handleSetMode} title="CamRecipe Pro" />
<View className="flex-1 bg-black">
<StatusBar barStyle="light-content" translucent backgroundColor="transparent" />
{/* Main Viewfinder Frame */}
<View className="flex-1 justify-center items-center px-4 py-2 bg-carbon">
{/* Fullscreen hero: photo or live preview fills the whole screen */}
<View className="absolute inset-0 bg-black">
<Viewfinder
mode={mode}
recipe={
@@ -384,41 +381,53 @@ export default function App() {
libraryImageUri={libraryImageUri}
cameraPermissionGranted={cameraPermissionGranted}
onRequestCameraPermission={requestCameraPermission}
cameraRef={cameraRef}
photoOutput={photoOutput}
/>
</View>
{/* Selector Tabs */}
<TabSelector activeTab={activeTab} setActiveTab={setActiveTab} />
{/* Top overlay: mode label + recipe name (left), Save current (right) */}
<TopBar mode={mode} recipeName={activeRecipe.name} onSaveRecipe={handleSaveRecipe} />
{/* Adjustments Panel */}
<View style={{ height: 250 }} className="bg-carbon">
<AdjustmentPanel
activeTab={activeTab}
recipes={recipes}
currentRecipeId={selectedRecipe?.id || ''}
adjustments={adjustments}
selectedFrame={selectedFrame}
useGeotag={useGeotag}
onSelectRecipe={applyRecipe}
onUpdateAdjustments={handleUpdateAdjustments}
onUpdateFrame={handleUpdateFrame}
onToggleGeotag={handleToggleGeotag}
onSaveRecipe={handleSaveRecipe}
onDeleteRecipe={handleDeleteRecipe}
{/* Bottom deck: chip panel (progressive disclosure) -> tool rail -> shutter */}
<View className="absolute left-0 right-0 bottom-0 z-10">
{openTab !== null && (
<View
style={{
backgroundColor: 'rgba(8,8,10,0.96)',
borderTopWidth: 1,
borderTopColor: 'rgba(63,63,70,0.7)',
}}
className="rounded-t-2xl overflow-hidden border-l border-r border-zinc-800/60"
>
<AdjustmentPanel
activeTab={openTab}
recipes={recipes}
currentRecipeId={selectedRecipe?.id || ''}
adjustments={adjustments}
selectedFrame={selectedFrame}
useGeotag={useGeotag}
onSelectRecipe={applyRecipe}
onUpdateAdjustments={handleUpdateAdjustments}
onUpdateFrame={handleUpdateFrame}
onToggleGeotag={handleToggleGeotag}
onSaveRecipe={handleSaveRecipe}
onDeleteRecipe={handleDeleteRecipe}
/>
</View>
)}
<ToolRail activeTab={openTab} onToggle={(tab) => setOpenTab(openTab === tab ? null : tab)} />
<CameraControls
lastPhotoUri={lastPhotoUri}
onCapture={handleCapture}
onPickImage={handlePickImage}
onOpenPreview={() => setPreviewVisible(true)}
isLibraryMode={mode === 'library'}
onSwitchToCamera={() => handleSetMode('camera')}
/>
</View>
{/* Bottom Camera Buttons */}
<CameraControls
lastPhotoUri={lastPhotoUri}
onCapture={handleCapture}
onPickImage={handlePickImage}
onOpenPreview={() => setPreviewVisible(true)}
isLibraryMode={mode === 'library'}
onSwitchToCamera={() => handleSetMode('camera')}
/>
{/* Preview Modal */}
<PreviewModal
visible={previewVisible}
@@ -437,6 +446,6 @@ export default function App() {
</Text>
</View>
)}
</SafeAreaView>
</View>
);
}