feat: v1.1 — highlight-weighted metering, settings modal, photo viewer, shutter sound control, cleanup
This commit is contained in:
+145
-103
@@ -3,8 +3,11 @@ 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, AspectRatio } from '../types';
|
||||
import { Recipe, ColorAdjustments, FrameId, BaseFilter } from '../types';
|
||||
import { FRAMES } from '../utils/frameUtils';
|
||||
import { PARAM_DEFS, ContinuousParamDef } from '../utils/paramDefs';
|
||||
import { FILM_SIMS, filmSimToRecipe } from '../utils/defaultRecipes';
|
||||
import RecipeCreateModal from './RecipeCreateModal';
|
||||
|
||||
// Compact bottom sheet above the tool rail:
|
||||
// bottom row = the tool's chips (presets / parameters), horizontally scrollable
|
||||
@@ -12,7 +15,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' | 'ratio';
|
||||
type TabId = 'recipes' | 'iq' | 'wb' | 'filters' | 'frame';
|
||||
// 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,16 +27,23 @@ 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;
|
||||
// RAW (DNG sidecar) / aspect-ratio / metering-mode now live in the gear
|
||||
// Settings sheet (gear between library picker and shutter) — no longer
|
||||
// tool-rail tabs.
|
||||
// The parameter whose slider row is open — lifted to App so it can also drive
|
||||
// drag-anywhere-on-the-image adjustment while the row is open.
|
||||
openParam: string | null;
|
||||
onOpenParamChange: (key: string | null) => void;
|
||||
// Base filter of the currently applied recipe — seeds the CREATE modal's
|
||||
// SIMULATION row so quick-input starts from the active look.
|
||||
currentBaseFilter?: BaseFilter;
|
||||
// CREATE quick-input saves a brand-new recipe (name + adjustments from the
|
||||
// modal) and applies it, unlike onSaveRecipe which snapshots the live state.
|
||||
onCreateRecipe: (recipe: Omit<Recipe, 'id' | 'isCustom'>) => void;
|
||||
onSelectRecipe: (recipe: Recipe) => void;
|
||||
onUpdateAdjustments: (adjustments: Partial<ColorAdjustments>) => 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;
|
||||
}
|
||||
@@ -62,8 +72,19 @@ interface ParamDef {
|
||||
}
|
||||
|
||||
const haptic = () => Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
||||
const sign = (v: number) => (v > 0 ? `+${v}` : String(v));
|
||||
const oneEv = (v: number) => `${v > 0 ? '+' : ''}${v.toFixed(1)} EV`;
|
||||
|
||||
// WB preset → engine pair. AUTO and DAYLIGHT both land on the neutral
|
||||
// 5500K / 0 pair (the engine has no scene meter), so the pair alone cannot say
|
||||
// which chip is active — the last tapped preset is remembered to disambiguate.
|
||||
const WB_PAIRS: Record<string, { t: number; ti: number }> = {
|
||||
auto: { t: 5500, ti: 0 },
|
||||
daylight: { t: 5500, ti: 0 },
|
||||
'daylight-3r': { t: 5500, ti: -3 },
|
||||
cloudy: { t: 6500, ti: 1 },
|
||||
shade: { t: 8000, ti: 2 },
|
||||
tungsten: { t: 3200, ti: 0 },
|
||||
fluorescent: { t: 4000, ti: 3 },
|
||||
};
|
||||
|
||||
export default function AdjustmentPanel({
|
||||
activeTab,
|
||||
@@ -72,31 +93,36 @@ export default function AdjustmentPanel({
|
||||
adjustments,
|
||||
selectedFrame,
|
||||
useGeotag,
|
||||
aspectRatio,
|
||||
rawEnabled,
|
||||
cameraMode,
|
||||
openParam,
|
||||
onOpenParamChange,
|
||||
currentBaseFilter,
|
||||
onCreateRecipe,
|
||||
onSelectRecipe,
|
||||
onUpdateAdjustments,
|
||||
onUpdateFrame,
|
||||
onToggleGeotag,
|
||||
onAspectRatio,
|
||||
onRawEnabled,
|
||||
onDeleteRecipe,
|
||||
}: AdjustmentPanelProps) {
|
||||
// The single parameter whose slider row is open — a new selection replaces
|
||||
// the previous row (one row at a time, above the chips).
|
||||
const [openParam, setOpenParam] = useState<string | null>(null);
|
||||
// The single choice-group whose strip (OFF/WEAK/STRONG, or the DR ladder) is
|
||||
// open above the chips (same one-row-at-a-time pattern as openParam).
|
||||
// open above the chips (same one-row-at-a-time pattern as openParam — which
|
||||
// lives in App now, since an open row also arms image-drag adjustment).
|
||||
const [openGroup, setOpenGroup] = useState<GroupKey | null>(null);
|
||||
// PRESETS tab only: RECIPES chip expands the user-recipe strip above the main
|
||||
// row; CREATE chip opens the quick-input modal.
|
||||
const [showRecipes, setShowRecipes] = useState(false);
|
||||
const [createVisible, setCreateVisible] = useState(false);
|
||||
// WB tab: preset last tapped while its engine pair is still in effect (AUTO
|
||||
// vs DAYLIGHT share the neutral pair, so selection can't be derived).
|
||||
const [wbChoice, setWbChoice] = useState<string | null>(null);
|
||||
useEffect(() => {
|
||||
setOpenParam(null);
|
||||
setOpenGroup(null);
|
||||
setShowRecipes(false);
|
||||
setCreateVisible(false);
|
||||
}, [activeTab]);
|
||||
|
||||
const toggleParam = (key: string) => {
|
||||
haptic();
|
||||
setOpenParam((prev) => (prev === key ? null : key));
|
||||
onOpenParamChange(openParam === key ? null : key);
|
||||
};
|
||||
|
||||
const toggleGroup = (g: GroupKey) => {
|
||||
@@ -178,51 +204,49 @@ export default function AdjustmentPanel({
|
||||
);
|
||||
|
||||
// ---- per-tab parameter definitions ---------------------------------------
|
||||
// Static bounds/labels/accessors live in PARAM_DEFS (shared with App, which
|
||||
// needs the same metadata to drive drag-anywhere adjustment); here each row
|
||||
// is bound to the live adjustments value + an update callback.
|
||||
const toRows = (defs: ContinuousParamDef[]): ParamDef[] =>
|
||||
defs.map((d) => ({
|
||||
key: d.key,
|
||||
label: d.label,
|
||||
value: d.get(a),
|
||||
default: d.defaultValue,
|
||||
min: d.min,
|
||||
max: d.max,
|
||||
step: d.step,
|
||||
display: d.display,
|
||||
onChange: (v: number) => onUpdateAdjustments(d.set(v)),
|
||||
}));
|
||||
const paramDefs: Record<TabId, ParamDef[]> = {
|
||||
recipes: [],
|
||||
iq: [
|
||||
{ key: 'exposure', label: 'EXPOSURE', value: a.exposure, default: 0, min: -10, max: 10, step: 1, display: sign, onChange: (v) => onUpdateAdjustments({ exposure: v }) },
|
||||
{ key: 'ev', label: 'EV', value: a.exposureCompensation ?? 0, default: 0, min: -3, max: 3, step: 0.5, display: oneEv, onChange: (v) => onUpdateAdjustments({ exposureCompensation: v }) },
|
||||
{ key: 'contrast', label: 'CONTRAST', value: a.contrast, default: 0, min: -10, max: 10, step: 1, display: sign, onChange: (v) => onUpdateAdjustments({ contrast: v }) },
|
||||
{ key: 'color', label: 'COLOR', value: a.saturation, default: 0, min: -10, max: 10, step: 1, display: sign, onChange: (v) => onUpdateAdjustments({ saturation: v }) },
|
||||
{ key: 'highlight', label: 'HIGHLIGHT', value: a.highlight ?? 0, default: 0, min: -10, max: 10, step: 1, display: sign, onChange: (v) => onUpdateAdjustments({ highlight: v }) },
|
||||
{ key: 'shadow', label: 'SHADOW', value: a.shadow ?? 0, default: 0, min: -10, max: 10, step: 1, display: sign, onChange: (v) => onUpdateAdjustments({ shadow: v }) },
|
||||
],
|
||||
wb: [
|
||||
{ key: 'temperature', label: 'COLOR TEMP', value: a.temperature, default: 5500, min: 2500, max: 10000, step: 100, display: (v) => `${v}K`, onChange: (v) => onUpdateAdjustments({ temperature: v }) },
|
||||
{ key: 'tint', label: 'TINT', value: a.tint ?? 0, default: 0, min: -10, max: 10, step: 1, display: sign, onChange: (v) => onUpdateAdjustments({ tint: v }) },
|
||||
],
|
||||
filters: [
|
||||
{ key: 'denoise', label: 'NOISE REDUCTION', value: a.denoise, default: 0, min: 0, max: 10, step: 1, display: String, onChange: (v) => onUpdateAdjustments({ denoise: v }) },
|
||||
{ key: 'clarity', label: 'CLARITY', value: a.clarity, default: 0, min: -10, max: 10, step: 1, display: sign, onChange: (v) => onUpdateAdjustments({ clarity: v }) },
|
||||
{ key: 'sharpening', label: 'SHARPENING', value: a.sharpening ?? 0, default: 0, min: 0, max: 10, step: 1, display: String, onChange: (v) => onUpdateAdjustments({ sharpening: v }) },
|
||||
{ key: 'grain', label: 'MONOCHROME GRAIN', value: a.grain, default: 0, min: 0, max: 10, step: 1, display: String, onChange: (v) => onUpdateAdjustments({ grain: v }) },
|
||||
],
|
||||
iq: toRows(PARAM_DEFS.iq),
|
||||
wb: toRows(PARAM_DEFS.wb),
|
||||
filters: toRows(PARAM_DEFS.filters),
|
||||
frame: [],
|
||||
ratio: [],
|
||||
};
|
||||
|
||||
const wbPresetOf = (): string => {
|
||||
// Active WB chip: the remembered preset wins while the engine pair still
|
||||
// matches it; otherwise derive from the pair (neutral ⇒ AUTO; any unlisted
|
||||
// pair also reads as AUTO — no preset is a stronger claim there).
|
||||
const wbActive = (): string => {
|
||||
const t = a.temperature ?? 5500;
|
||||
const ti = a.tint ?? 0;
|
||||
if (t === 5500 && ti === -3) return 'daylight-3r';
|
||||
if (t === 6500 && ti === 1) return 'cloudy';
|
||||
if (t === 8000 && ti === 2) return 'shade';
|
||||
if (t === 3200 && ti === 0) return 'tungsten';
|
||||
if (t === 4000 && ti === 3) return 'fluorescent';
|
||||
if (t === 5500 && ti === 0) return 'daylight';
|
||||
return 'auto';
|
||||
if (wbChoice) {
|
||||
const p = WB_PAIRS[wbChoice];
|
||||
if (p && p.t === t && p.ti === ti) return wbChoice;
|
||||
}
|
||||
const hit = (Object.keys(WB_PAIRS) as string[]).find((k) => {
|
||||
const p = WB_PAIRS[k];
|
||||
return p.t === t && p.ti === ti;
|
||||
});
|
||||
return hit ?? 'auto';
|
||||
};
|
||||
const wbApply = (val: string) => {
|
||||
switch (val) {
|
||||
case 'daylight': return onUpdateAdjustments({ temperature: 5500, tint: 0 });
|
||||
case 'daylight-3r': return onUpdateAdjustments({ temperature: 5500, tint: -3 });
|
||||
case 'cloudy': return onUpdateAdjustments({ temperature: 6500, tint: 1 });
|
||||
case 'shade': return onUpdateAdjustments({ temperature: 8000, tint: 2 });
|
||||
case 'tungsten': return onUpdateAdjustments({ temperature: 3200, tint: 0 });
|
||||
case 'fluorescent': return onUpdateAdjustments({ temperature: 4000, tint: 3 });
|
||||
default: return onUpdateAdjustments({ temperature: 5500, tint: 0 });
|
||||
}
|
||||
setWbChoice(val);
|
||||
const p = WB_PAIRS[val] ?? WB_PAIRS.auto;
|
||||
onUpdateAdjustments({ temperature: p.t, tint: p.ti });
|
||||
};
|
||||
|
||||
// Choice-groups: one toggle chip sits in the chip row (COLOR CHROME /
|
||||
@@ -270,21 +294,29 @@ export default function AdjustmentPanel({
|
||||
const chipsRow = (() => {
|
||||
switch (activeTab) {
|
||||
case 'recipes': {
|
||||
if (recipes.length === 0) {
|
||||
return (
|
||||
<View className="px-3 py-2">
|
||||
<Text className="font-mono text-[10px] text-zinc-500">NO PRESETS - TAP SAVE ABOVE</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
const chips: ChipDef[] = recipes.map((r) => ({
|
||||
key: r.id,
|
||||
label: r.name,
|
||||
active: r.id === currentRecipeId,
|
||||
trash: !!r.isCustom,
|
||||
onPress: () => onSelectRecipe(r),
|
||||
onPressDelete: () => onDeleteRecipe(r.id),
|
||||
}));
|
||||
// Main row: the six standard film simulations, then the RECIPES toggle
|
||||
// (expands the user/bundled recipe strip above) and CREATE (quick-input
|
||||
// modal). Recipes themselves are no longer inline chips here.
|
||||
const chips: ChipDef[] = [
|
||||
...FILM_SIMS.map((s) => ({
|
||||
key: s.id,
|
||||
label: s.name,
|
||||
active: currentRecipeId === s.id,
|
||||
onPress: () => onSelectRecipe(filmSimToRecipe(s)),
|
||||
})),
|
||||
{
|
||||
key: 'recipes-toggle',
|
||||
label: 'RECIPES',
|
||||
active: showRecipes,
|
||||
onPress: () => setShowRecipes((v) => !v),
|
||||
},
|
||||
{
|
||||
key: 'create',
|
||||
label: 'CREATE',
|
||||
active: false,
|
||||
onPress: () => setCreateVisible(true),
|
||||
},
|
||||
];
|
||||
return (
|
||||
<ScrollView
|
||||
horizontal
|
||||
@@ -330,7 +362,7 @@ export default function AdjustmentPanel({
|
||||
{ v: 'tungsten', d: 'TUNGSTEN' },
|
||||
{ v: 'fluorescent', d: 'FLUOR' },
|
||||
],
|
||||
wbPresetOf(),
|
||||
wbActive(),
|
||||
wbApply
|
||||
)}
|
||||
{paramChips(paramDefs.wb)}
|
||||
@@ -372,36 +404,6 @@ export default function AdjustmentPanel({
|
||||
</ScrollView>
|
||||
);
|
||||
|
||||
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 (
|
||||
<ScrollView
|
||||
horizontal
|
||||
showsHorizontalScrollIndicator={false}
|
||||
contentContainerStyle={{ alignItems: 'center', paddingHorizontal: 8, paddingVertical: 8 }}
|
||||
>
|
||||
{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)
|
||||
)}
|
||||
</ScrollView>
|
||||
);
|
||||
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
@@ -469,7 +471,47 @@ export default function AdjustmentPanel({
|
||||
</ScrollView>
|
||||
</View>
|
||||
)}
|
||||
{/* PRESETS: expanded recipe strip (above the main chip row) while the
|
||||
RECIPES chip is open — bundled defaults + user-created, custom
|
||||
deletable inline. */}
|
||||
{activeTab === 'recipes' && showRecipes && (
|
||||
<View className="border-b border-zinc-800/60">
|
||||
<ScrollView
|
||||
horizontal
|
||||
showsHorizontalScrollIndicator={false}
|
||||
contentContainerStyle={{ alignItems: 'center', paddingHorizontal: 8, paddingVertical: 6 }}
|
||||
>
|
||||
{recipes.length === 0
|
||||
? renderChip({
|
||||
key: 'no-recipes',
|
||||
label: 'NO RECIPES YET - TAP CREATE',
|
||||
disabled: true,
|
||||
active: false,
|
||||
onPress: () => {},
|
||||
})
|
||||
: recipes.map((r) =>
|
||||
renderChip({
|
||||
key: r.id,
|
||||
label: r.name,
|
||||
active: r.id === currentRecipeId,
|
||||
trash: !!r.isCustom,
|
||||
onPress: () => onSelectRecipe(r),
|
||||
onPressDelete: () => onDeleteRecipe(r.id),
|
||||
})
|
||||
)}
|
||||
</ScrollView>
|
||||
</View>
|
||||
)}
|
||||
{chipsRow}
|
||||
<RecipeCreateModal
|
||||
visible={createVisible}
|
||||
currentBase={currentBaseFilter}
|
||||
onClose={() => setCreateVisible(false)}
|
||||
onSave={(recipe) => {
|
||||
onCreateRecipe(recipe);
|
||||
setShowRecipes(true);
|
||||
}}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
import React from 'react';
|
||||
import { View, Text, TouchableOpacity, Image, StyleSheet } from 'react-native';
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
import { Image as ImageIcon, Eye, Download, Camera } from 'lucide-react-native';
|
||||
import { Image as ImageIcon, Eye, Download, Camera, Settings } from 'lucide-react-native';
|
||||
|
||||
interface CameraControlsProps {
|
||||
lastPhotoUri: string | null;
|
||||
onCapture: () => void;
|
||||
onPickImage: () => void;
|
||||
onOpenPreview: () => void;
|
||||
onOpenSettings: () => void;
|
||||
isLibraryMode: boolean;
|
||||
onSwitchToCamera?: () => void;
|
||||
}
|
||||
@@ -17,6 +18,7 @@ export default function CameraControls({
|
||||
onCapture,
|
||||
onPickImage,
|
||||
onOpenPreview,
|
||||
onOpenSettings,
|
||||
isLibraryMode,
|
||||
onSwitchToCamera,
|
||||
}: CameraControlsProps) {
|
||||
@@ -27,14 +29,24 @@ export default function CameraControls({
|
||||
style={{ paddingBottom: insets.bottom + 10 }}
|
||||
className="flex-row items-center justify-between px-7 pt-2"
|
||||
>
|
||||
{/* Pick Image Button (left) */}
|
||||
<TouchableOpacity
|
||||
onPress={onPickImage}
|
||||
className="w-12 h-12 rounded-full bg-black/40 border border-zinc-700/70 flex items-center justify-center"
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<ImageIcon size={20} color="#e4e4e7" strokeWidth={1.75} />
|
||||
</TouchableOpacity>
|
||||
{/* Left cluster: Pick Image + gear (gear sits between library & shutter) */}
|
||||
<View className="flex-row items-center space-x-3">
|
||||
<TouchableOpacity
|
||||
onPress={onPickImage}
|
||||
className="w-12 h-12 rounded-full bg-black/40 border border-zinc-700/70 flex items-center justify-center"
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<ImageIcon size={20} color="#e4e4e7" strokeWidth={1.75} />
|
||||
</TouchableOpacity>
|
||||
|
||||
<TouchableOpacity
|
||||
onPress={onOpenSettings}
|
||||
className="w-12 h-12 rounded-full bg-black/40 border border-zinc-700/70 flex items-center justify-center"
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<Settings size={20} color="#e4e4e7" strokeWidth={1.75} />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
{/* Dynamic Center Button: Shutter or Save */}
|
||||
<TouchableOpacity
|
||||
@@ -60,9 +72,7 @@ export default function CameraControls({
|
||||
{/* Right Action Button: Eye (preview) or Camera (switch back) */}
|
||||
<TouchableOpacity
|
||||
onPress={isLibraryMode && onSwitchToCamera ? onSwitchToCamera : onOpenPreview}
|
||||
className={`w-12 h-12 rounded-full bg-black/40 border border-zinc-700/70 flex items-center justify-center overflow-hidden ${
|
||||
isLibraryMode ? '' : ''
|
||||
}`}
|
||||
className="w-12 h-12 rounded-full bg-black/40 border border-zinc-700/70 flex items-center justify-center overflow-hidden"
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
{isLibraryMode ? (
|
||||
|
||||
@@ -0,0 +1,219 @@
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import { View, Text, Modal, Image, TouchableOpacity, ScrollView, Share, Alert, useWindowDimensions } from 'react-native';
|
||||
import * as Sharing from 'expo-sharing';
|
||||
import { X, Share2, Download, Trash2 } from 'lucide-react-native';
|
||||
import { PhotoEntry } from '../types';
|
||||
|
||||
// Session photo viewer: swipe left/right across every photo taken/exported in
|
||||
// this session. Top shows preset name + index/total; trash deletes the photo
|
||||
// being viewed (confirm modal first); share/save as before. Opened from the
|
||||
// thumbnail button (camera mode) or automatically after a library export —
|
||||
// NOT after a camera capture (that only fills the thumbnail).
|
||||
interface PhotoViewerModalProps {
|
||||
visible: boolean;
|
||||
photos: PhotoEntry[];
|
||||
initialIndex: number;
|
||||
onClose: () => void;
|
||||
// Remove photo id from the session history (App also deletes cache + gallery copy).
|
||||
onDelete: (id: string) => void;
|
||||
// Save the (unsaved) photo id to the gallery; App stores the resulting assetId.
|
||||
onSave: (id: string) => Promise<void>;
|
||||
}
|
||||
|
||||
export default function PhotoViewerModal({
|
||||
visible,
|
||||
photos,
|
||||
initialIndex,
|
||||
onClose,
|
||||
onDelete,
|
||||
onSave,
|
||||
}: PhotoViewerModalProps) {
|
||||
const { width } = useWindowDimensions();
|
||||
const [index, setIndex] = useState(Math.max(0, Math.min(initialIndex, photos.length - 1)));
|
||||
const scrollRef = useRef<ScrollView>(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const wasOpen = useRef(false);
|
||||
|
||||
// Opening the modal (or reopening on a different photo): jump to initialIndex.
|
||||
// Shrinks of the list while open (deletion) must NOT re-scroll — the strip
|
||||
// shifts left by one page and the same page slot keeps showing the next photo.
|
||||
useEffect(() => {
|
||||
if (!visible) {
|
||||
wasOpen.current = false;
|
||||
return;
|
||||
}
|
||||
if (!wasOpen.current) {
|
||||
wasOpen.current = true;
|
||||
if (photos.length > 0) {
|
||||
const i = Math.max(0, Math.min(initialIndex, photos.length - 1));
|
||||
setIndex(i);
|
||||
requestAnimationFrame(() => {
|
||||
scrollRef.current?.scrollTo({ x: i * width, animated: false });
|
||||
});
|
||||
}
|
||||
}
|
||||
}, [visible, initialIndex, width, photos.length]);
|
||||
|
||||
// Keep the page state in bounds when the list shrinks underneath us.
|
||||
useEffect(() => {
|
||||
if (visible && photos.length > 0 && index >= photos.length) {
|
||||
const i = photos.length - 1;
|
||||
setIndex(i);
|
||||
scrollRef.current?.scrollTo({ x: i * width, animated: false });
|
||||
}
|
||||
}, [visible, index, photos.length, width]);
|
||||
|
||||
const photo = photos[index] || null;
|
||||
|
||||
const handleShare = async () => {
|
||||
if (!photo) return;
|
||||
try {
|
||||
if (await Sharing.isAvailableAsync()) {
|
||||
await Sharing.shareAsync(photo.uri, {
|
||||
mimeType: 'image/jpeg',
|
||||
dialogTitle: `Share with CamRecipe Pro - Preset: ${photo.recipeName}`,
|
||||
UTI: 'public.jpeg',
|
||||
});
|
||||
} else {
|
||||
await Share.share({
|
||||
url: photo.uri,
|
||||
message: `Captured with CamRecipe Pro - Preset: ${photo.recipeName}`,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Sharing failed:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeletePress = () => {
|
||||
if (!photo) return;
|
||||
if (photo.savedToLibrary) {
|
||||
// The gallery copy exists → Android itself shows its per-item delete
|
||||
// confirmation, so no app modal in front of it.
|
||||
onDelete(photo.id);
|
||||
return;
|
||||
}
|
||||
// Never reached the gallery — no system prompt will happen, keep an app
|
||||
// confirm as the only guard against an accidental trash tap.
|
||||
Alert.alert(
|
||||
'Delete Photo',
|
||||
'Delete this photo? It was never saved to your gallery.',
|
||||
[
|
||||
{ text: 'Cancel', style: 'cancel' },
|
||||
{ text: 'Delete', style: 'destructive', onPress: () => onDelete(photo.id) },
|
||||
]
|
||||
);
|
||||
};
|
||||
|
||||
const handleDone = async () => {
|
||||
if (!photo || photo.savedToLibrary || saving) {
|
||||
onClose();
|
||||
return;
|
||||
}
|
||||
setSaving(true);
|
||||
try {
|
||||
await onSave(photo.id);
|
||||
onClose();
|
||||
} catch (e) {
|
||||
console.error('Save failed:', e);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal visible={visible} animationType="fade" transparent={false} onRequestClose={onClose}>
|
||||
<View className="flex-1 bg-black">
|
||||
{/* Top bar: preset + index/total (left), trash + close (right) */}
|
||||
<View className="pt-12 pb-2 px-4 flex-row justify-between items-center">
|
||||
<View className="flex-1 pr-2">
|
||||
<Text className="text-zinc-500 font-mono text-xs">
|
||||
{photo ? photo.recipeName.toUpperCase() : ''}
|
||||
</Text>
|
||||
<Text className="text-zinc-600 font-mono text-[10px] mt-0.5">
|
||||
{photos.length > 0 ? `${index + 1} / ${photos.length}` : ''}
|
||||
</Text>
|
||||
</View>
|
||||
<View className="flex-row items-center">
|
||||
{photo && (
|
||||
<TouchableOpacity
|
||||
onPress={handleDeletePress}
|
||||
className="w-9 h-9 rounded-full bg-zinc-900 flex items-center justify-center mr-2"
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<Trash2 size={17} color="#ef4444" />
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
<TouchableOpacity
|
||||
onPress={onClose}
|
||||
className="w-9 h-9 rounded-full bg-zinc-900 flex items-center justify-center"
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<X size={18} color="#ffffff" />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Swipeable photo strip */}
|
||||
{photos.length > 0 ? (
|
||||
<ScrollView
|
||||
ref={scrollRef}
|
||||
horizontal
|
||||
pagingEnabled
|
||||
showsHorizontalScrollIndicator={false}
|
||||
onMomentumScrollEnd={(e) => {
|
||||
const i = Math.round(e.nativeEvent.contentOffset.x / width);
|
||||
setIndex(Math.max(0, Math.min(i, photos.length - 1)));
|
||||
}}
|
||||
>
|
||||
{photos.map((p) => (
|
||||
<View key={p.id} style={{ width }} className="justify-center items-center">
|
||||
<Image
|
||||
source={{ uri: p.uri }}
|
||||
style={{ width, height: '100%' }}
|
||||
className=""
|
||||
resizeMode="contain"
|
||||
/>
|
||||
</View>
|
||||
))}
|
||||
</ScrollView>
|
||||
) : (
|
||||
<View className="flex-1 items-center justify-center">
|
||||
<Text className="text-zinc-400 font-mono text-sm">NO PHOTOS</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Bottom bar */}
|
||||
<View className="pb-10 pt-3 px-4">
|
||||
{photo && !photo.savedToLibrary && (
|
||||
<Text className="text-amber-500 font-mono text-[10px] text-center mb-2">
|
||||
NOT SAVED TO GALLERY - SAVE & DONE TO KEEP
|
||||
</Text>
|
||||
)}
|
||||
<View className="flex-row justify-around items-center px-4">
|
||||
<TouchableOpacity
|
||||
onPress={handleShare}
|
||||
disabled={!photo}
|
||||
className="flex-row items-center bg-zinc-900 border border-zinc-800 px-6 py-3 rounded-full"
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<Share2 size={16} color="#f59e0b" />
|
||||
<Text className="text-white font-mono text-xs font-bold ml-2">SHARE</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity
|
||||
onPress={handleDone}
|
||||
disabled={!photo || saving}
|
||||
className="flex-row items-center bg-amber-500 px-6 py-3 rounded-full"
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<Download size={16} color="#000000" />
|
||||
<Text className="text-black font-mono text-xs font-bold ml-2">
|
||||
{saving ? 'SAVING...' : photo && !photo.savedToLibrary ? 'SAVE & DONE' : 'DONE'}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -1,153 +0,0 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { View, Text, Modal, Image, TouchableOpacity, Share, Alert } from 'react-native';
|
||||
import * as Sharing from 'expo-sharing';
|
||||
import * as MediaLibrary from 'expo-media-library/legacy';
|
||||
import { X, Share2, Download } from 'lucide-react-native';
|
||||
|
||||
interface PreviewModalProps {
|
||||
visible: boolean;
|
||||
onClose: () => void;
|
||||
photoUri: string | null;
|
||||
recipeName: string;
|
||||
savedToLibrary: boolean; // whether the export was already saved to the gallery
|
||||
}
|
||||
|
||||
export default function PreviewModal({
|
||||
visible,
|
||||
onClose,
|
||||
photoUri,
|
||||
recipeName,
|
||||
savedToLibrary: initialSaved,
|
||||
}: PreviewModalProps) {
|
||||
const [saved, setSaved] = useState(initialSaved);
|
||||
|
||||
// Modal stays mounted — refresh state whenever a new export is shown.
|
||||
useEffect(() => {
|
||||
if (visible) setSaved(initialSaved);
|
||||
}, [visible, initialSaved]);
|
||||
|
||||
const handleShare = async () => {
|
||||
if (!photoUri) return;
|
||||
try {
|
||||
// expo-sharing actually shares the file; React Native's Share.share cannot
|
||||
// attach local files. Fall back to Share on platforms where it's unavailable.
|
||||
if (await Sharing.isAvailableAsync()) {
|
||||
await Sharing.shareAsync(photoUri, {
|
||||
mimeType: 'image/jpeg',
|
||||
dialogTitle: `Share with CamRecipe Pro - Preset: ${recipeName}`,
|
||||
UTI: 'public.jpeg',
|
||||
});
|
||||
} else {
|
||||
await Share.share({
|
||||
url: photoUri,
|
||||
message: `Captured with CamRecipe Pro - Preset: ${recipeName}`,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Sharing failed:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDone = async () => {
|
||||
if (!photoUri || saved) {
|
||||
onClose();
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const perm = await MediaLibrary.requestPermissionsAsync();
|
||||
if (!perm.granted) {
|
||||
Alert.alert(
|
||||
'Not Saved',
|
||||
'Media library access was denied, so this photo was not saved to your gallery. You can still share it from here.'
|
||||
);
|
||||
onClose();
|
||||
return;
|
||||
}
|
||||
await MediaLibrary.saveToLibraryAsync(photoUri);
|
||||
setSaved(true);
|
||||
onClose();
|
||||
} catch (error) {
|
||||
console.error('Save to gallery failed:', error);
|
||||
Alert.alert('Save Failed', 'Could not save this photo to your gallery.');
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
if (photoUri && !saved) {
|
||||
Alert.alert(
|
||||
'Not Saved to Gallery',
|
||||
'This photo lives in the app cache only. Tap DONE to save a copy to your gallery, or SHARE it now.'
|
||||
);
|
||||
return;
|
||||
}
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
visible={visible}
|
||||
animationType="fade"
|
||||
transparent={false}
|
||||
onRequestClose={handleClose}
|
||||
>
|
||||
<View className="flex-1 bg-black justify-between py-6 px-4">
|
||||
{/* Top bar */}
|
||||
<View className="flex-row justify-between items-center px-2">
|
||||
<Text className="text-zinc-500 font-mono text-xs">PRESET: {recipeName.toUpperCase()}</Text>
|
||||
<TouchableOpacity
|
||||
onPress={handleClose}
|
||||
className="w-8 h-8 rounded-full bg-zinc-900 flex items-center justify-center"
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<X size={18} color="#ffffff" />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
{/* Image display */}
|
||||
<View className="flex-1 justify-center items-center my-4">
|
||||
{photoUri ? (
|
||||
<Image
|
||||
source={{ uri: photoUri }}
|
||||
className="w-full aspect-[3/4] max-h-[75vh] rounded-lg border border-zinc-800"
|
||||
resizeMode="contain"
|
||||
/>
|
||||
) : (
|
||||
<Text className="text-zinc-400 font-mono text-sm">NO IMAGE CAPTURED</Text>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* Bottom bar */}
|
||||
<View>
|
||||
{photoUri && !saved && (
|
||||
<Text className="text-amber-500 font-mono text-[10px] text-center mb-2">
|
||||
NOT SAVED TO GALLERY - TAP DONE TO SAVE
|
||||
</Text>
|
||||
)}
|
||||
<View className="flex-row justify-around items-center px-4">
|
||||
<TouchableOpacity
|
||||
onPress={handleShare}
|
||||
disabled={!photoUri}
|
||||
className="flex-row items-center space-x-2 bg-zinc-900 border border-zinc-800 px-6 py-3 rounded-full"
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<Share2 size={16} color="#f59e0b" />
|
||||
<Text className="text-white font-mono text-xs font-bold">SHARE</Text>
|
||||
</TouchableOpacity>
|
||||
|
||||
<TouchableOpacity
|
||||
onPress={handleDone}
|
||||
className="flex-row items-center space-x-2 bg-amber-500 px-6 py-3 rounded-full"
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<Download size={16} color="#000000" />
|
||||
<Text className="text-black font-mono text-xs font-bold">
|
||||
{saved ? 'DONE' : 'SAVE & DONE'}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,417 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
TextInput,
|
||||
ScrollView,
|
||||
TouchableOpacity,
|
||||
Modal,
|
||||
KeyboardAvoidingView,
|
||||
Platform,
|
||||
} from 'react-native';
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
import * as Haptics from 'expo-haptics';
|
||||
import { Recipe, ColorAdjustments, BaseFilter } from '../types';
|
||||
import { DEFAULT_ADJUSTMENTS, FILM_SIMS } from '../utils/defaultRecipes';
|
||||
|
||||
// "CREATE" quick-input form shown above the viewfinder. Numeric knobs are
|
||||
// plain textboxes (typed fast, one row each), categorical knobs are chip
|
||||
// dropdowns. Used to enter Fuji-style recipes (DR/Highlight/Shadow/Color/NR/
|
||||
// Sharpness/Clarity/Grain/CC/CCB/WB+shift/ISO/EV) without touching sliders.
|
||||
|
||||
const haptic = () => Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
||||
|
||||
interface RecipeCreateModalProps {
|
||||
visible: boolean;
|
||||
// Id of the currently applied film sim (or bundled recipe whose look should
|
||||
// prefill the SIMULATION row); undefined → default to PROVIA.
|
||||
currentBase?: BaseFilter;
|
||||
onClose: () => void;
|
||||
onSave: (recipe: Omit<Recipe, 'id' | 'isCustom'>) => void;
|
||||
}
|
||||
|
||||
// WB presets mirror the WB-tab chips but keep pure presets (no -R variants —
|
||||
// those are the separate Red/Blue offset textboxes below).
|
||||
const WB_PRESETS: { key: string; label: string; temperature: number; tint: number }[] = [
|
||||
{ key: 'auto', label: 'AUTO', temperature: 5500, tint: 0 },
|
||||
{ key: 'daylight', label: 'DAYLIGHT', temperature: 5500, tint: 0 },
|
||||
{ key: 'cloudy', label: 'CLOUDY', temperature: 6500, tint: 1 },
|
||||
{ key: 'shade', label: 'SHADE', temperature: 8000, tint: 2 },
|
||||
{ key: 'tungsten', label: 'TUNGSTEN', temperature: 3200, tint: 0 },
|
||||
{ key: 'fluor', label: 'FLUOR', temperature: 4000, tint: 3 },
|
||||
];
|
||||
const WB_KEYS = WB_PRESETS.map((w) => w.key);
|
||||
|
||||
const DR_OPTS = [
|
||||
{ v: 'auto', d: 'AUTO' },
|
||||
{ v: '100', d: 'DR100' },
|
||||
{ v: '200', d: 'DR200' },
|
||||
{ v: '400', d: 'DR400' },
|
||||
] as const;
|
||||
const CC_OPTS = [
|
||||
{ v: 'none', d: 'OFF' },
|
||||
{ v: 'weak', d: 'WEAK' },
|
||||
{ v: 'strong', d: 'STRONG' },
|
||||
] as const;
|
||||
const GRAIN_OPTS = [
|
||||
{ v: '0', d: 'OFF' },
|
||||
{ v: '3', d: 'WEAK' },
|
||||
{ v: '6', d: 'STRONG' },
|
||||
] as const;
|
||||
|
||||
// ISO has no manual control path on this build (the vision-camera lib only
|
||||
// exposes exposure locking on iOS; Android CameraX keeps ISO automatic), so the
|
||||
// modal shows AUTO. If a native path ever appears, feed isoSupported=true and
|
||||
// the row below turns into a selectable chip list.
|
||||
const ISO_SUPPORTED = false;
|
||||
const ISO_STOPS = [200, 400, 800, 1600, 3200, 6400];
|
||||
|
||||
const clamp = (v: number, lo: number, hi: number) => Math.max(lo, Math.min(hi, v));
|
||||
const int = (s: string, fallback: number, lo: number, hi: number) => {
|
||||
const n = parseInt(s, 10);
|
||||
return Number.isFinite(n) ? clamp(n, lo, hi) : fallback;
|
||||
};
|
||||
|
||||
interface ChipProps {
|
||||
label: string;
|
||||
active: boolean;
|
||||
onPress: () => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
const Chip = ({ label, active, onPress, disabled }: ChipProps) => (
|
||||
<TouchableOpacity
|
||||
onPress={() => { if (!disabled) { haptic(); onPress(); } }}
|
||||
className={`mx-0.5 rounded-full border px-2.5 py-1 ${
|
||||
disabled
|
||||
? 'border-zinc-800 bg-black/30'
|
||||
: active
|
||||
? 'border-amber-500/70 bg-amber-500/15'
|
||||
: 'border-zinc-700/60 bg-titan/80'
|
||||
}`}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<Text
|
||||
className={`font-mono text-[10px] font-bold tracking-wide ${
|
||||
disabled ? 'text-zinc-600' : active ? 'text-amber-500' : 'text-zinc-300'
|
||||
}`}
|
||||
>
|
||||
{label}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
|
||||
const ChipRow = ({ children }: { children: React.ReactNode }) => (
|
||||
<ScrollView
|
||||
horizontal
|
||||
showsHorizontalScrollIndicator={false}
|
||||
contentContainerStyle={{ alignItems: 'center', paddingVertical: 2 }}
|
||||
>
|
||||
{children}
|
||||
</ScrollView>
|
||||
);
|
||||
|
||||
const LABEL_W = 132;
|
||||
|
||||
interface NumRowProps {
|
||||
label: string;
|
||||
value: string;
|
||||
onChange: (s: string) => void;
|
||||
placeholder?: string;
|
||||
width?: number;
|
||||
}
|
||||
const NumRow = ({ label, value, onChange, placeholder, width = 58 }: NumRowProps) => (
|
||||
<View style={{ width: '48%' }} className="mb-1.5">
|
||||
<Text className="text-zinc-400 font-mono text-[9px] font-bold tracking-widest mb-0.5">
|
||||
{label}
|
||||
</Text>
|
||||
<TextInput
|
||||
value={value}
|
||||
onChangeText={onChange}
|
||||
keyboardType="numbers-and-punctuation"
|
||||
placeholder={placeholder ?? '0'}
|
||||
placeholderTextColor="#52525b"
|
||||
className="bg-carbon border border-zinc-800 rounded-md px-2 py-1 text-white font-mono text-sm text-right"
|
||||
style={{ width }}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
|
||||
export default function RecipeCreateModal({
|
||||
visible,
|
||||
currentBase,
|
||||
onClose,
|
||||
onSave,
|
||||
}: RecipeCreateModalProps) {
|
||||
const insets = useSafeAreaInsets();
|
||||
// Seed from the current film sim (or PROVIA) so "other params" start at the
|
||||
// sim's standard values and the user only types what they want to change.
|
||||
const seedBase: BaseFilter =
|
||||
currentBase && FILM_SIMS.some((s) => s.baseFilter === currentBase) ? currentBase : 'provia';
|
||||
const seedAdj = FILM_SIMS.find((s) => s.baseFilter === seedBase)?.adjustments ?? DEFAULT_ADJUSTMENTS;
|
||||
|
||||
// Every sim's standard values as form state — used to seed the modal and to
|
||||
// reload the form when the SIMULATION chip changes.
|
||||
const valuesOf = (b: BaseFilter) => {
|
||||
const s = FILM_SIMS.find((x) => x.baseFilter === b)?.adjustments ?? DEFAULT_ADJUSTMENTS;
|
||||
return {
|
||||
dr: String(s.dynamicRange ?? 'auto'),
|
||||
grain: String(s.grain === 0 ? 0 : s.grain >= 6 ? 6 : 3),
|
||||
cc: s.colorChrome ?? 'none',
|
||||
ccb: s.colorChromeBlue ?? 'none',
|
||||
wbKey: WB_KEYS.find((k) => {
|
||||
const p = WB_PRESETS.find((w) => w.key === k)!;
|
||||
return p.temperature === s.temperature && p.tint === s.tint;
|
||||
}) ?? 'auto',
|
||||
highlight: String(s.highlight),
|
||||
shadow: String(s.shadow),
|
||||
color: String(s.saturation),
|
||||
nr: String(s.denoise),
|
||||
sharp: String(s.sharpening),
|
||||
clarity: String(s.clarity),
|
||||
ev: String(s.exposureCompensation),
|
||||
};
|
||||
};
|
||||
|
||||
const [sim, setSim] = useState<BaseFilter>(seedBase);
|
||||
const [name, setName] = useState('');
|
||||
const [dr, setDr] = useState<string>(seedAdj.dynamicRange == null ? 'auto' : String(seedAdj.dynamicRange));
|
||||
const [grain, setGrain] = useState<string>(String(seedAdj.grain === 0 ? 0 : seedAdj.grain >= 6 ? 6 : 3));
|
||||
const [cc, setCc] = useState<string>(seedAdj.colorChrome ?? 'none');
|
||||
const [ccb, setCcb] = useState<string>(seedAdj.colorChromeBlue ?? 'none');
|
||||
const [wbKey, setWbKey] = useState<string>(
|
||||
WB_KEYS.find((k) => {
|
||||
const p = WB_PRESETS.find((w) => w.key === k)!;
|
||||
return p.temperature === seedAdj.temperature && p.tint === seedAdj.tint;
|
||||
}) ?? 'auto'
|
||||
);
|
||||
const [wbRed, setWbRed] = useState('0');
|
||||
const [wbBlue, setWbBlue] = useState('0');
|
||||
const [fHigh, setFHigh] = useState(String(seedAdj.highlight));
|
||||
const [fShadow, setFShadow] = useState(String(seedAdj.shadow));
|
||||
const [fColor, setFColor] = useState(String(seedAdj.saturation));
|
||||
const [fNr, setFNr] = useState(String(seedAdj.denoise));
|
||||
const [fSharp, setFSharp] = useState(String(seedAdj.sharpening));
|
||||
const [fClarity, setFClarity] = useState(String(seedAdj.clarity));
|
||||
const [fEv, setFEv] = useState(String(seedAdj.exposureCompensation));
|
||||
const [iso, setIso] = useState<number | null>(null);
|
||||
|
||||
// Re-seed whenever the modal opens (or the applied look changes) so CREATE
|
||||
// quick-input always starts from the current simulation.
|
||||
useEffect(() => {
|
||||
if (!visible) return;
|
||||
const v = valuesOf(seedBase);
|
||||
setSim(seedBase);
|
||||
setDr(v.dr);
|
||||
setGrain(v.grain);
|
||||
setCc(v.cc);
|
||||
setCcb(v.ccb);
|
||||
setWbKey(v.wbKey);
|
||||
setWbRed('0');
|
||||
setWbBlue('0');
|
||||
setFHigh(v.highlight);
|
||||
setFShadow(v.shadow);
|
||||
setFColor(v.color);
|
||||
setFNr(v.nr);
|
||||
setFSharp(v.sharp);
|
||||
setFClarity(v.clarity);
|
||||
setFEv(v.ev);
|
||||
setName('');
|
||||
setIso(null);
|
||||
}, [visible, currentBase]);
|
||||
|
||||
// Switching the simulation reloads its standard values into the form.
|
||||
const selectSim = (b: BaseFilter) => {
|
||||
setSim(b);
|
||||
const v = valuesOf(b);
|
||||
setDr(v.dr);
|
||||
setGrain(v.grain);
|
||||
setCc(v.cc);
|
||||
setCcb(v.ccb);
|
||||
setWbKey(v.wbKey);
|
||||
setFHigh(v.highlight);
|
||||
setFShadow(v.shadow);
|
||||
setFColor(v.color);
|
||||
setFNr(v.nr);
|
||||
setFSharp(v.sharp);
|
||||
setFClarity(v.clarity);
|
||||
setFEv(v.ev);
|
||||
};
|
||||
|
||||
const handleSave = () => {
|
||||
const wb = WB_PRESETS.find((w) => w.key === wbKey) ?? WB_PRESETS[0];
|
||||
const simAdj = FILM_SIMS.find((s) => s.baseFilter === sim)?.adjustments ?? DEFAULT_ADJUSTMENTS;
|
||||
const adj: ColorAdjustments = {
|
||||
...DEFAULT_ADJUSTMENTS,
|
||||
...simAdj,
|
||||
temperature: wb.temperature,
|
||||
tint: wb.tint,
|
||||
wbRed: int(wbRed, 0, -9, 9),
|
||||
wbBlue: int(wbBlue, 0, -9, 9),
|
||||
highlight: int(fHigh, simAdj.highlight, -10, 10),
|
||||
shadow: int(fShadow, simAdj.shadow, -10, 10),
|
||||
saturation: int(fColor, simAdj.saturation, -10, 10),
|
||||
denoise: int(fNr, simAdj.denoise, 0, 10),
|
||||
sharpening: int(fSharp, simAdj.sharpening, 0, 10),
|
||||
clarity: int(fClarity, simAdj.clarity, -10, 10),
|
||||
grain: parseInt(grain, 10),
|
||||
colorChrome: cc as ColorAdjustments['colorChrome'],
|
||||
colorChromeBlue: ccb as ColorAdjustments['colorChromeBlue'],
|
||||
dynamicRange: (dr === 'auto' ? 'auto' : parseInt(dr, 10)) as ColorAdjustments['dynamicRange'],
|
||||
exposureCompensation: int(fEv, simAdj.exposureCompensation, -3, 3),
|
||||
};
|
||||
onSave({
|
||||
name: name.trim() || 'NEW RECIPE',
|
||||
baseFilter: sim,
|
||||
adjustments: adj,
|
||||
frameId: 'none',
|
||||
useGeotag: true,
|
||||
});
|
||||
setName('');
|
||||
onClose();
|
||||
};
|
||||
|
||||
const fieldSets: { label: string; value: string; set: (s: string) => void; ph?: string }[] = [
|
||||
{ label: 'HIGHLIGHT', value: fHigh, set: setFHigh },
|
||||
{ label: 'SHADOW', value: fShadow, set: setFShadow },
|
||||
{ label: 'COLOR', value: fColor, set: setFColor },
|
||||
{ label: 'NOISE RED.', value: fNr, set: setFNr, ph: '0-10' },
|
||||
{ label: 'SHARPENING', value: fSharp, set: setFSharp, ph: '0-10' },
|
||||
{ label: 'CLARITY', value: fClarity, set: setFClarity },
|
||||
{ label: 'EXPOSURE COMP.', value: fEv, set: setFEv, ph: 'EV' },
|
||||
];
|
||||
|
||||
return (
|
||||
<Modal transparent animationType="slide" visible={visible} onRequestClose={onClose}>
|
||||
<View className="absolute inset-0 bg-black/60" />
|
||||
<KeyboardAvoidingView style={{ flex: 1 }} behavior={Platform.OS === 'ios' ? 'padding' : 'height'}>
|
||||
<View
|
||||
style={{ paddingTop: insets.top + 12, paddingBottom: insets.bottom + 12, maxHeight: '100%' }}
|
||||
className="bg-titan border-b border-zinc-800 rounded-b-3xl px-4 pt-1"
|
||||
>
|
||||
<View className="flex-row items-center justify-between mb-2">
|
||||
<Text className="text-white font-mono text-sm font-bold tracking-wider">CREATE RECIPE</Text>
|
||||
<TouchableOpacity onPress={onClose} className="px-2 py-1" activeOpacity={0.7}>
|
||||
<Text className="text-zinc-400 font-mono text-xs font-bold">✕</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
<ScrollView showsVerticalScrollIndicator={false} contentContainerStyle={{ paddingBottom: 8 }}>
|
||||
{/* Name */}
|
||||
<Text className="text-zinc-400 font-mono text-[9px] font-bold tracking-widest mb-0.5">NAME</Text>
|
||||
<TextInput
|
||||
value={name}
|
||||
onChangeText={setName}
|
||||
placeholder="Recipe name (e.g. Fuji Retro)"
|
||||
placeholderTextColor="#71717a"
|
||||
className="bg-carbon border border-zinc-800 rounded-lg p-2 text-white font-mono text-sm mb-2"
|
||||
/>
|
||||
|
||||
{/* Simulation */}
|
||||
<Text className="text-zinc-400 font-mono text-[9px] font-bold tracking-widest mb-0.5">SIMULATION</Text>
|
||||
<ChipRow>
|
||||
{FILM_SIMS.map((s) => (
|
||||
<Chip key={s.id} label={s.name} active={sim === s.baseFilter} onPress={() => selectSim(s.baseFilter)} />
|
||||
))}
|
||||
</ChipRow>
|
||||
|
||||
{/* Dynamic range */}
|
||||
<Text className="text-zinc-400 font-mono text-[9px] font-bold tracking-widest mt-1.5 mb-0.5">DYNAMIC RANGE</Text>
|
||||
<ChipRow>
|
||||
{DR_OPTS.map((o) => (
|
||||
<Chip key={o.v} label={o.d} active={dr === o.v} onPress={() => setDr(o.v)} />
|
||||
))}
|
||||
</ChipRow>
|
||||
|
||||
{/* Numeric textboxes — 2 per row */}
|
||||
<View className="flex-row flex-wrap justify-between mt-1.5">
|
||||
{fieldSets.map((f) => (
|
||||
<NumRow key={f.label} label={f.label} value={f.value} onChange={f.set} placeholder={f.ph} />
|
||||
))}
|
||||
</View>
|
||||
|
||||
{/* Grain / chrome */}
|
||||
<View className="mt-1">
|
||||
<Text className="text-zinc-400 font-mono text-[9px] font-bold tracking-widest mb-0.5">GRAIN EFFECT</Text>
|
||||
<ChipRow>
|
||||
{GRAIN_OPTS.map((o) => (
|
||||
<Chip key={o.v} label={o.d} active={grain === o.v} onPress={() => setGrain(o.v)} />
|
||||
))}
|
||||
</ChipRow>
|
||||
<Text className="text-zinc-400 font-mono text-[9px] font-bold tracking-widest mt-1.5 mb-0.5">COLOR CHROME EFFECT</Text>
|
||||
<ChipRow>
|
||||
{CC_OPTS.map((o) => (
|
||||
<Chip key={o.v} label={o.d} active={cc === o.v} onPress={() => setCc(o.v)} />
|
||||
))}
|
||||
</ChipRow>
|
||||
<Text className="text-zinc-400 font-mono text-[9px] font-bold tracking-widest mt-1.5 mb-0.5">COLOR CHROME EFFECT BLUE</Text>
|
||||
<ChipRow>
|
||||
{CC_OPTS.map((o) => (
|
||||
<Chip key={o.v} label={o.d} active={ccb === o.v} onPress={() => setCcb(o.v)} />
|
||||
))}
|
||||
</ChipRow>
|
||||
</View>
|
||||
|
||||
{/* White balance + R/B shift */}
|
||||
<Text className="text-zinc-400 font-mono text-[9px] font-bold tracking-widest mt-1.5 mb-0.5">WHITE BALANCE</Text>
|
||||
<ChipRow>
|
||||
{WB_PRESETS.map((w) => (
|
||||
<Chip key={w.key} label={w.label} active={wbKey === w.key} onPress={() => setWbKey(w.key)} />
|
||||
))}
|
||||
</ChipRow>
|
||||
<View className="flex-row items-center mt-1">
|
||||
<Text style={{ width: LABEL_W }} className="text-zinc-400 font-mono text-[9px] font-bold tracking-widest">
|
||||
WB SHIFT
|
||||
</Text>
|
||||
<Text className="text-zinc-500 font-mono text-[9px] mr-1.5">RED</Text>
|
||||
<TextInput
|
||||
value={wbRed}
|
||||
onChangeText={setWbRed}
|
||||
keyboardType="numbers-and-punctuation"
|
||||
placeholder="-9..9"
|
||||
placeholderTextColor="#52525b"
|
||||
className="bg-carbon border border-zinc-800 rounded-md px-2 py-1 text-white font-mono text-sm text-right mr-3"
|
||||
style={{ width: 52 }}
|
||||
/>
|
||||
<Text className="text-zinc-500 font-mono text-[9px] mr-1.5">BLUE</Text>
|
||||
<TextInput
|
||||
value={wbBlue}
|
||||
onChangeText={setWbBlue}
|
||||
keyboardType="numbers-and-punctuation"
|
||||
placeholder="-9..9"
|
||||
placeholderTextColor="#52525b"
|
||||
className="bg-carbon border border-zinc-800 rounded-md px-2 py-1 text-white font-mono text-sm text-right"
|
||||
style={{ width: 52 }}
|
||||
/>
|
||||
</View>
|
||||
|
||||
{/* ISO */}
|
||||
<View className="flex-row items-center mt-1.5 mb-1">
|
||||
<Text style={{ width: LABEL_W }} className="text-zinc-400 font-mono text-[9px] font-bold tracking-widest">
|
||||
ISO
|
||||
</Text>
|
||||
{ISO_SUPPORTED ? (
|
||||
<ChipRow>
|
||||
<Chip label="AUTO" active={iso === null} onPress={() => setIso(null)} />
|
||||
{ISO_STOPS.map((s) => (
|
||||
<Chip key={s} label={String(s)} active={iso === s} onPress={() => setIso(s)} />
|
||||
))}
|
||||
</ChipRow>
|
||||
) : (
|
||||
<Chip label="AUTO (camera default)" active disabled onPress={() => {}} />
|
||||
)}
|
||||
</View>
|
||||
</ScrollView>
|
||||
|
||||
<View className="flex-row space-x-3 mt-2">
|
||||
<TouchableOpacity onPress={onClose} className="flex-1 bg-zinc-800 py-3 rounded-lg items-center" activeOpacity={0.7}>
|
||||
<Text className="text-zinc-300 font-mono text-xs font-semibold">CANCEL</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity onPress={handleSave} className="flex-1 bg-amber-500 py-3 rounded-lg items-center" activeOpacity={0.7}>
|
||||
<Text className="text-black font-mono text-xs font-bold">SAVE RECIPE</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
</KeyboardAvoidingView>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
import React from 'react';
|
||||
import { View, Text, Modal, TouchableOpacity } from 'react-native';
|
||||
import * as Haptics from 'expo-haptics';
|
||||
import { X, Volume2, Settings as SettingsIcon } from 'lucide-react-native';
|
||||
import { AspectRatio, MeterMode, ShutterSound } from '../types';
|
||||
|
||||
// Gear-sheet (gear button between library picker and shutter). Groups the
|
||||
// capture/composition choices that are NOT look-adjustments, so the tool rail
|
||||
// keeps only the image tools: shutter sound, composition ratio + RAW DNG
|
||||
// sidecar, and full-frame metering mode (ex-FOCUS tab → METERING MODE).
|
||||
interface Chip {
|
||||
key: string;
|
||||
label: string;
|
||||
active: boolean;
|
||||
onPress: () => void;
|
||||
disabled?: boolean; // informational chip, not tappable (CAMERA ONLY etc.)
|
||||
}
|
||||
|
||||
const haptic = () => Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
||||
|
||||
interface SettingsModalProps {
|
||||
visible: boolean;
|
||||
onClose: () => void;
|
||||
aspectRatio: AspectRatio;
|
||||
onAspectRatio: (ratio: AspectRatio) => void;
|
||||
rawEnabled: boolean;
|
||||
onRawEnabled: (enabled: boolean) => void;
|
||||
cameraMode: boolean;
|
||||
meterMode: MeterMode;
|
||||
onMeterModeChange: (mode: MeterMode) => void;
|
||||
shutterSound: ShutterSound;
|
||||
onShutterSound: (sound: ShutterSound) => void;
|
||||
}
|
||||
|
||||
function ChipRow({ chips }: { chips: Chip[] }) {
|
||||
return (
|
||||
<View className="flex-row flex-wrap items-center">
|
||||
{chips.map((c) =>
|
||||
c.disabled ? (
|
||||
<View
|
||||
key={c.key}
|
||||
className="mx-1 flex-row items-center rounded-full border border-zinc-800/80 bg-black/30 px-3 py-1.5"
|
||||
>
|
||||
<Text className="font-mono text-[11px] font-semibold text-zinc-500">{c.label}</Text>
|
||||
</View>
|
||||
) : (
|
||||
<TouchableOpacity
|
||||
key={c.key}
|
||||
onPress={() => { haptic(); c.onPress(); }}
|
||||
activeOpacity={0.7}
|
||||
className={`mx-1 flex-row items-center rounded-full border px-3 py-1.5 ${
|
||||
c.active ? 'border-amber-500/70 bg-amber-500/15' : 'border-zinc-700/60 bg-titan/80'
|
||||
}`}
|
||||
>
|
||||
<Text className={`font-mono text-[11px] font-bold tracking-wide ${c.active ? 'text-amber-500' : 'text-zinc-300'}`}>
|
||||
{c.label}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
)
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function SectionLabel({ children }: { children: string }) {
|
||||
return (
|
||||
<Text className="text-zinc-500 font-mono text-[10px] tracking-[0.2em] mb-2">{children}</Text>
|
||||
);
|
||||
}
|
||||
|
||||
function CameraOnlyNote({ cameraMode }: { cameraMode: boolean }) {
|
||||
if (cameraMode) return null;
|
||||
return <Text className="text-zinc-600 font-mono text-[10px] mt-1.5 ml-1">CAMERA ONLY</Text>;
|
||||
}
|
||||
|
||||
export default function SettingsModal({
|
||||
visible,
|
||||
onClose,
|
||||
aspectRatio,
|
||||
onAspectRatio,
|
||||
rawEnabled,
|
||||
onRawEnabled,
|
||||
cameraMode,
|
||||
meterMode,
|
||||
onMeterModeChange,
|
||||
shutterSound,
|
||||
onShutterSound,
|
||||
}: SettingsModalProps) {
|
||||
return (
|
||||
<Modal visible={visible} transparent animationType="fade" onRequestClose={onClose}>
|
||||
<View className="flex-1 bg-black/70 items-center justify-center px-6">
|
||||
<TouchableOpacity
|
||||
className="absolute inset-0"
|
||||
activeOpacity={1}
|
||||
onPress={onClose}
|
||||
accessibilityLabel="Close settings"
|
||||
/>
|
||||
<View
|
||||
className="w-full max-w-md rounded-2xl border border-zinc-800/70 p-5 pb-6 space-y-5"
|
||||
style={{ backgroundColor: 'rgba(8,8,10,0.97)' }}
|
||||
>
|
||||
<View className="flex-row items-center justify-between px-1">
|
||||
<View className="flex-row items-center">
|
||||
<SettingsIcon size={16} color="#a1a1aa" strokeWidth={1.75} />
|
||||
<Text className="text-zinc-400 font-mono text-xs font-bold tracking-widest ml-2">
|
||||
SETTINGS
|
||||
</Text>
|
||||
</View>
|
||||
<TouchableOpacity
|
||||
onPress={onClose}
|
||||
className="w-8 h-8 rounded-full bg-zinc-900 flex items-center justify-center"
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<X size={16} color="#ffffff" />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
{/* Shutter sound */}
|
||||
<View>
|
||||
<SectionLabel>SHUTTER SOUND</SectionLabel>
|
||||
<ChipRow
|
||||
chips={[
|
||||
{ key: 'snd-on', label: 'ON', active: shutterSound === 'on', onPress: () => onShutterSound('on') },
|
||||
{ key: 'snd-off', label: 'OFF', active: shutterSound === 'off', onPress: () => onShutterSound('off') },
|
||||
]}
|
||||
/>
|
||||
<View className="flex-row items-center mt-1.5 ml-1">
|
||||
<Volume2 size={11} color="#52525b" strokeWidth={1.75} />
|
||||
<Text className="text-zinc-600 font-mono text-[10px] ml-1.5">
|
||||
Android has no click-volume control — OFF only. Some regions force the sound on by OS policy.
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Composition ratio + RAW */}
|
||||
<View>
|
||||
<SectionLabel>PHOTO RATIO</SectionLabel>
|
||||
<ChipRow
|
||||
chips={[
|
||||
{ key: 'ratio-full', label: 'FULL', active: aspectRatio === 'full', onPress: () => onAspectRatio('full') },
|
||||
{ key: 'ratio-4:3', label: '4:3', active: aspectRatio === '4:3', onPress: () => onAspectRatio('4:3') },
|
||||
{ key: 'ratio-3:2', label: '3:2', active: aspectRatio === '3:2', onPress: () => onAspectRatio('3:2') },
|
||||
]}
|
||||
/>
|
||||
<View className="mt-2">
|
||||
<ChipRow
|
||||
chips={[
|
||||
cameraMode
|
||||
? { key: 'raw', label: rawEnabled ? 'RAW DNG ON' : 'RAW DNG OFF', active: rawEnabled, onPress: () => onRawEnabled(!rawEnabled) }
|
||||
: { key: 'raw', label: rawEnabled ? 'RAW DNG ON' : 'RAW DNG OFF', active: rawEnabled, disabled: true, onPress: () => {} },
|
||||
]}
|
||||
/>
|
||||
<CameraOnlyNote cameraMode={cameraMode} />
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Metering mode */}
|
||||
<View>
|
||||
<SectionLabel>METERING MODE</SectionLabel>
|
||||
<ChipRow
|
||||
chips={[
|
||||
cameraMode
|
||||
? { key: 'meter-eval', label: 'EVALUATIVE', active: meterMode === 'evaluative', onPress: () => onMeterModeChange('evaluative') }
|
||||
: { key: 'meter-eval', label: 'EVALUATIVE', active: meterMode === 'evaluative', disabled: true, onPress: () => {} },
|
||||
cameraMode
|
||||
? { key: 'meter-hl', label: 'HIGHLIGHT-WEIGHTED', active: meterMode === 'highlight', onPress: () => onMeterModeChange('highlight') }
|
||||
: { key: 'meter-hl', label: 'HIGHLIGHT-WEIGHTED', active: meterMode === 'highlight', disabled: true, onPress: () => {} },
|
||||
]}
|
||||
/>
|
||||
<CameraOnlyNote cameraMode={cameraMode} />
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -1,9 +1,9 @@
|
||||
import React from 'react';
|
||||
import { View, Text, TouchableOpacity, ScrollView } from 'react-native';
|
||||
import { LayoutGrid, Sun, Thermometer, Sparkles, Frame, Ratio, type LucideIcon } from 'lucide-react-native';
|
||||
import { LayoutGrid, Sun, Thermometer, Sparkles, Frame, type LucideIcon } from 'lucide-react-native';
|
||||
import * as Haptics from 'expo-haptics';
|
||||
|
||||
export type TabId = 'recipes' | 'iq' | 'wb' | 'filters' | 'frame' | 'ratio';
|
||||
export type TabId = 'recipes' | 'iq' | 'wb' | 'filters' | 'frame';
|
||||
|
||||
interface ToolRailProps {
|
||||
activeTab: TabId | null;
|
||||
@@ -16,7 +16,6 @@ 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) {
|
||||
|
||||
+177
-73
@@ -23,7 +23,7 @@ import {
|
||||
MipmapMode,
|
||||
} from '@shopify/react-native-skia';
|
||||
import { Lock } from 'lucide-react-native';
|
||||
import { Recipe, GPSInfo, FrameId, AspectRatio, ASPECT_RATIO_W_H } from '../types';
|
||||
import { Recipe, GPSInfo, FrameId, AspectRatio, ColorAdjustments, 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';
|
||||
@@ -52,6 +52,31 @@ interface ViewfinderProps {
|
||||
onSessionError?: (error: Error) => void;
|
||||
// Quick exposure slider (shown while AE/AF is locked) — live AE bias in EV.
|
||||
onExposureChange?: (ev: number) => void;
|
||||
// Metering-mode EV offset (highlight-weighted): extra EV stops ADDED on top
|
||||
// of the recipe EV. Applied to the preview matrix AND the capture-time AE
|
||||
// flush so preview/export/capture agree; the EV slider UI keeps showing the
|
||||
// raw user EV. Negative = protect highlights. 0 = off.
|
||||
meteringEvOffset?: number;
|
||||
// Metering-mode tonal look (highlight-weighted): small extra adjustments
|
||||
// merged on top of the recipe for RENDERING only (preview matrix + tone, and
|
||||
// App mirrors it into the export). Real highlight-weighted metering (GR /
|
||||
// Sony) doesn't only underexpose — it meters for the BRIGHTEST area to keep
|
||||
// its detail (absolute no-blowout): highlight roll, deep shadow crush, raised
|
||||
// contrast/clarity. Never contains exposureCompensation (that rides the EV
|
||||
// offset above); UI sliders keep showing the raw recipe.
|
||||
meteringAdjustments?: Partial<ColorAdjustments>;
|
||||
// Continuous panel parameter whose slider row is open (App lifts it from the
|
||||
// AdjustmentPanel). While set, a vertical drag ANYWHERE on the live image
|
||||
// adjusts this parameter (up = increase) — no need to grab the small slider.
|
||||
// Null while no param row is open; then a locked-AE drag adjusts EV instead.
|
||||
imageAdjustTarget?: {
|
||||
key: string;
|
||||
min: number;
|
||||
max: number;
|
||||
step: number;
|
||||
value: number;
|
||||
onChange: (v: number) => void;
|
||||
} | null;
|
||||
}
|
||||
|
||||
export interface ViewfinderHandle {
|
||||
@@ -81,7 +106,7 @@ const toneEffect = (() => {
|
||||
try {
|
||||
return Skia.RuntimeEffect.Make(TONE_SKSL);
|
||||
} catch (e) {
|
||||
console.error('[camtone] TONE_SKSL compile failed: ' + e);
|
||||
console.error('TONE_SKSL compile failed: ' + e);
|
||||
return null;
|
||||
}
|
||||
})();
|
||||
@@ -90,7 +115,7 @@ const cinemaEffect = (() => {
|
||||
try {
|
||||
return Skia.RuntimeEffect.Make(CINEMA_SKSL);
|
||||
} catch (e) {
|
||||
console.error('[cinema] CINEMA_SKSL compile failed: ' + e);
|
||||
console.error('CINEMA_SKSL compile failed: ' + e);
|
||||
return null;
|
||||
}
|
||||
})();
|
||||
@@ -118,6 +143,9 @@ const Viewfinder = forwardRef<ViewfinderHandle, ViewfinderProps>(function Viewfi
|
||||
rawOutput,
|
||||
onSessionError,
|
||||
onExposureChange,
|
||||
meteringEvOffset = 0,
|
||||
meteringAdjustments,
|
||||
imageAdjustTarget,
|
||||
}: ViewfinderProps,
|
||||
ref
|
||||
) {
|
||||
@@ -212,14 +240,20 @@ const Viewfinder = forwardRef<ViewfinderHandle, ViewfinderProps>(function Viewfi
|
||||
Math.round(vw * 0.032)
|
||||
);
|
||||
|
||||
const adjustments = recipe.adjustments;
|
||||
const adjustments = meteringAdjustments
|
||||
? { ...recipe.adjustments, ...meteringAdjustments }
|
||||
: recipe.adjustments;
|
||||
// Exposure compensation, applied as software 2^EV matrix gain for BOTH modes.
|
||||
// Camera AE bias is only flushed to hardware at capture time
|
||||
// (flushExposureBias): setting it live per slider value re-locks AE on the
|
||||
// Xiaomi and flashes the preview (matrix change is instant and flicker-free).
|
||||
// Metering offset (highlight-weighted) rides on top of the user EV so the
|
||||
// preview shows the protected-exposure look; EV_RANGE ±3 caps the software
|
||||
// gain (flush clamps to the device range separately).
|
||||
const evApplied = Math.max(-3, Math.min(3, (adjustments.exposureCompensation ?? 0) + meteringEvOffset));
|
||||
const colorMatrix = applyExposureGain(
|
||||
getSkiaColorMatrix(recipe.baseFilter, adjustments),
|
||||
adjustments.exposureCompensation ?? 0
|
||||
evApplied
|
||||
);
|
||||
|
||||
// The Skia onFrame worklet runs on a separate runtime — keep the latest color
|
||||
@@ -259,6 +293,15 @@ const Viewfinder = forwardRef<ViewfinderHandle, ViewfinderProps>(function Viewfi
|
||||
// flicker-free (preview EV is software matrix gain).
|
||||
const skiaCameraRef = useRef<SkiaCameraRef>(null);
|
||||
const [cameraActive, setCameraActive] = useState(false);
|
||||
// Stable identities so React.memo(SkiaCameraImpl) does not re-render (and
|
||||
// re-run its frame-output setup) on every adjustments change during slider
|
||||
// drags — inline lambdas here used to break memo and stall the main thread.
|
||||
const handleCameraStarted = useCallback(() => {
|
||||
setCameraActive(true);
|
||||
}, []);
|
||||
const handleCameraStopped = useCallback(() => {
|
||||
setCameraActive(false);
|
||||
}, []);
|
||||
// Camera session pauses when the app backgrounds; toggling isActive back on
|
||||
// resume restarts it. Without this the preview stayed black after unlock.
|
||||
const [appActive, setAppActive] = useState(AppState.currentState === 'active');
|
||||
@@ -275,15 +318,16 @@ const Viewfinder = forwardRef<ViewfinderHandle, ViewfinderProps>(function Viewfi
|
||||
// per intermediate value — each one cancelled by the next, spamming [ev]).
|
||||
const evStopsRef = useRef(evStops);
|
||||
evStopsRef.current = evStops;
|
||||
// Metering offset mirrored into a ref: flushExposureBias is a stable
|
||||
// imperative handle (useImperativeHandle deps [ref]) and must read the LIVE
|
||||
// offset when the user switches metering mode.
|
||||
const meteringEvOffsetRef = useRef(meteringEvOffset);
|
||||
meteringEvOffsetRef.current = meteringEvOffset;
|
||||
useEffect(() => {
|
||||
if (!controller) return;
|
||||
const d = controller.device;
|
||||
if (d && d.supportsExposureBias) {
|
||||
console.error(
|
||||
`[ev] device range=${d.minExposureBias}..${d.maxExposureBias} current=${controller.exposureBias}`
|
||||
);
|
||||
} else {
|
||||
console.error(`[ev] device has no exposure bias support`);
|
||||
if (d && !d.supportsExposureBias) {
|
||||
console.warn('Device has no exposure-bias support — EV applies to preview and export only.');
|
||||
}
|
||||
}, [controller]);
|
||||
// Hardware AE bias is applied ONLY at capture time via flushExposureBias()
|
||||
@@ -300,17 +344,16 @@ const Viewfinder = forwardRef<ViewfinderHandle, ViewfinderProps>(function Viewfi
|
||||
const ctl = skiaCameraRef.current?.controller;
|
||||
const d = ctl?.device;
|
||||
if (!ctl || !d || !d.supportsExposureBias) return;
|
||||
const ev = evStopsRef.current;
|
||||
const ev = evStopsRef.current + meteringEvOffsetRef.current;
|
||||
const index = Math.round(ev * 8);
|
||||
const clamped = Math.max(d.minExposureBias, Math.min(d.maxExposureBias, index));
|
||||
if (clamped === ctl.exposureBias) return;
|
||||
for (let attempt = 1; attempt <= 3; attempt++) {
|
||||
try {
|
||||
await ctl.setExposureBias(clamped);
|
||||
console.error(`[ev] flush ev=${ev} idx=${clamped} ok, read=${ctl.exposureBias}`);
|
||||
return;
|
||||
} catch (e) {
|
||||
console.error(`[ev] flush ev=${ev} idx=${clamped} attempt=${attempt} failed: ${String(e).slice(0, 100)}`);
|
||||
console.warn(`Exposure-bias flush failed (attempt ${attempt}): ${String(e).slice(0, 100)}`);
|
||||
await new Promise((r) => setTimeout(r, 80));
|
||||
}
|
||||
}
|
||||
@@ -330,7 +373,7 @@ const Viewfinder = forwardRef<ViewfinderHandle, ViewfinderProps>(function Viewfi
|
||||
const d = cam?.controller?.device;
|
||||
if (!d) return;
|
||||
if (!d.supportsFocusMetering && !d.supportsExposureMetering && !d.supportsWhiteBalanceMetering) {
|
||||
console.error('[focus] metering unsupported on this device');
|
||||
console.warn('Focus/exposure metering unsupported on this device');
|
||||
return;
|
||||
}
|
||||
setFocusPoint({ x, y });
|
||||
@@ -339,9 +382,8 @@ const Viewfinder = forwardRef<ViewfinderHandle, ViewfinderProps>(function Viewfi
|
||||
{ x, y },
|
||||
{ responsiveness: 'snappy', adaptiveness: 'locked', autoResetAfter: null }
|
||||
);
|
||||
console.error(`[focus] tap=${Math.round(x)},${Math.round(y)} AE/AF/AWB locked`);
|
||||
} catch (e) {
|
||||
console.error('[focus] lock failed: ' + String(e).slice(0, 120));
|
||||
console.warn('Focus lock failed: ' + String(e).slice(0, 120));
|
||||
}
|
||||
};
|
||||
const handleUnlockFocus = async () => {
|
||||
@@ -350,9 +392,8 @@ const Viewfinder = forwardRef<ViewfinderHandle, ViewfinderProps>(function Viewfi
|
||||
try {
|
||||
await cam.resetFocus();
|
||||
setFocusPoint(null);
|
||||
console.error('[focus] reset to continuous auto');
|
||||
} catch (e) {
|
||||
console.error('[focus] reset failed: ' + String(e).slice(0, 120));
|
||||
console.warn('Focus reset failed: ' + String(e).slice(0, 120));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -365,41 +406,114 @@ const Viewfinder = forwardRef<ViewfinderHandle, ViewfinderProps>(function Viewfi
|
||||
const evPxPerStop = evTrackH / (2 * EV_RANGE);
|
||||
const clampEv = (v: number) => Math.max(-EV_RANGE, Math.min(EV_RANGE, v));
|
||||
const evDragRef = useRef<{ y0: number; ev0: number } | null>(null);
|
||||
// Last EV value actually pushed to App. Quick-EV drags otherwise fire a full
|
||||
// adjustments re-render on EVERY pointer move (30-60Hz), even while the
|
||||
// finger holds a step without the value changing — that render storm stalls
|
||||
// the main thread and beats the Skia preview. Only push real nấc changes.
|
||||
const evLastSentRef = useRef<number | null>(null);
|
||||
const evThumbY = evTrackTop + ((EV_RANGE - clampEv(evStops)) / (2 * EV_RANGE)) * evTrackH;
|
||||
// Screen-absolute Y of the gesture. locationY is relative to whichever view
|
||||
// the finger currently hit-tests over, so crossing the small track's edge
|
||||
// mid-drag changed the origin by the track offset and snapped EV by ~3 stops
|
||||
// (the "jumps to the other end" bug). pageY never moves.
|
||||
const pageYOf = (e: any): number => {
|
||||
const t = e?.nativeEvent?.touches?.[0];
|
||||
if (t && typeof t.pageY === 'number') return t.pageY;
|
||||
const p = e?.nativeEvent?.pageY;
|
||||
if (typeof p === 'number') return p;
|
||||
return e?.nativeEvent?.locationY ?? 0;
|
||||
};
|
||||
const onEvSliderGrant = (e: any) => {
|
||||
evDragRef.current = { y0: e.nativeEvent.locationY, ev0: evStops };
|
||||
const y0 = pageYOf(e);
|
||||
evDragRef.current = { y0, ev0: evStops };
|
||||
evLastSentRef.current = evStops;
|
||||
};
|
||||
const onEvSliderMove = (e: any) => {
|
||||
const g = evDragRef.current;
|
||||
if (!g || !onExposureChange) return;
|
||||
const dy = g.y0 - e.nativeEvent.locationY; // drag up = brighter
|
||||
const dy = g.y0 - pageYOf(e); // drag up = brighter
|
||||
const ev = clampEv(g.ev0 + dy / evPxPerStop);
|
||||
onExposureChange(Math.round(ev * 10) / 10);
|
||||
const rounded = Math.round(ev * 10) / 10;
|
||||
if (rounded === evLastSentRef.current) return; // same step: skip re-render
|
||||
evLastSentRef.current = rounded;
|
||||
onExposureChange(rounded);
|
||||
};
|
||||
const onEvSliderRelease = () => {
|
||||
evDragRef.current = null;
|
||||
};
|
||||
|
||||
// Instrumentation for device logs (user captures only logcat E level, so
|
||||
// console.log/warn never show up — these are error-level on purpose).
|
||||
const lastPreviewLog = useRef(0);
|
||||
useEffect(() => {
|
||||
const now = Date.now();
|
||||
if (now - lastPreviewLog.current < 400) return;
|
||||
lastPreviewLog.current = now;
|
||||
console.error(
|
||||
`[preview] matrix=(${colorMatrix[0].toFixed(3)},${colorMatrix[5].toFixed(3)},${colorMatrix[10].toFixed(3)}) tone=[${toneParams.join(',')}] ev=${evStops}`
|
||||
);
|
||||
}, [colorMatrix, toneParams, evStops]);
|
||||
// ---- Drag-anywhere-on-the-image adjustment --------------------------------
|
||||
// Priority: an open panel param row (imageAdjustTarget) > EV (AE locked).
|
||||
// A plain tap (no movement) always falls through to tap-to-focus.
|
||||
const TAP_SLOP = 14; // px of movement before a touch counts as a drag
|
||||
const imageAdjustTargetRef = useRef(imageAdjustTarget);
|
||||
imageAdjustTargetRef.current = imageAdjustTarget;
|
||||
type ImgDragSession = {
|
||||
y0: number;
|
||||
kind: 'param' | 'ev' | 'focus';
|
||||
v0: number;
|
||||
param: { min: number; max: number; step: number; onChange: (v: number) => void };
|
||||
moved: boolean;
|
||||
lastSent: number | null;
|
||||
};
|
||||
const imgDragRef = useRef<ImgDragSession | null>(null);
|
||||
const onImageDragGrant = (e: any) => {
|
||||
const t = imageAdjustTargetRef.current;
|
||||
let kind: ImgDragSession['kind'] = 'focus';
|
||||
let v0 = 0;
|
||||
let param: ImgDragSession['param'] = { min: 0, max: 0, step: 0, onChange: () => {} };
|
||||
if (t) {
|
||||
kind = 'param';
|
||||
v0 = t.value;
|
||||
param = { min: t.min, max: t.max, step: t.step, onChange: t.onChange };
|
||||
} else if (focusPoint && onExposureChange) {
|
||||
kind = 'ev';
|
||||
v0 = evStops;
|
||||
}
|
||||
imgDragRef.current = { y0: pageYOf(e), kind, v0, param, moved: false, lastSent: null };
|
||||
};
|
||||
const onImageDragMove = (e: any) => {
|
||||
const g = imgDragRef.current;
|
||||
if (!g || g.kind === 'focus') return;
|
||||
const dy = g.y0 - pageYOf(e); // up = positive / increase
|
||||
if (!g.moved) {
|
||||
if (Math.abs(dy) < TAP_SLOP) return;
|
||||
g.moved = true;
|
||||
}
|
||||
if (g.kind === 'ev') {
|
||||
if (!onExposureChange) return;
|
||||
const ev = clampEv(g.v0 + dy / evPxPerStop);
|
||||
const rounded = Math.round(ev * 10) / 10;
|
||||
if (rounded === g.lastSent) return;
|
||||
g.lastSent = rounded;
|
||||
evLastSentRef.current = rounded;
|
||||
onExposureChange(rounded);
|
||||
return;
|
||||
}
|
||||
const range = g.param.max - g.param.min;
|
||||
let v = g.v0 + (dy * range) / evTrackH; // same feel as the EV track
|
||||
v = Math.max(g.param.min, Math.min(g.param.max, v));
|
||||
if (g.param.step > 0) v = Math.round(v / g.param.step) * g.param.step;
|
||||
const rounded = Math.round(v * 1000) / 1000;
|
||||
if (rounded === g.lastSent) return;
|
||||
g.lastSent = rounded;
|
||||
g.param.onChange(rounded);
|
||||
};
|
||||
const onImageDragEnd = (e: any) => {
|
||||
const g = imgDragRef.current;
|
||||
imgDragRef.current = null;
|
||||
if (!g) return;
|
||||
if (!g.moved) {
|
||||
// Plain tap: lock focus at the tapped point (drag never re-locks).
|
||||
handleTapFocus(e.nativeEvent.locationX, e.nativeEvent.locationY);
|
||||
}
|
||||
};
|
||||
|
||||
// Camera frame renderer. Stable identity (empty-ish deps): SkiaCamera re-runs
|
||||
// its frame pipeline whenever onFrame changes, which during slider drags made
|
||||
// the preview flicker. All live values flow through the synchronizables.
|
||||
type SkiaCameraOnFrame = NonNullable<ComponentProps<typeof SkiaCamera>['onFrame']>;
|
||||
const toneFailLogged = useMemo(() => createSynchronizable<boolean>(false), []);
|
||||
// DEBUG-4x3 (remove after diagnosis): log actual stream geometry at runtime.
|
||||
const dbgCountSync = useMemo(() => createSynchronizable<number>(0), []);
|
||||
const dbgLastSync = useMemo(() => createSynchronizable<string>(''), []);
|
||||
// Composition band fed to the frame worklet: [active, viewAspect, fx, fy,
|
||||
// fw, fh] where (fx,fy,fw,fh) is the photo-area rect (ratio band / polaroid
|
||||
// window / wall window) normalized to the view. While active the worklet
|
||||
@@ -418,10 +532,23 @@ const Viewfinder = forwardRef<ViewfinderHandle, ViewfinderProps>(function Viewfi
|
||||
(frame, render) => {
|
||||
'worklet';
|
||||
render(({ canvas, frameTexture }) => {
|
||||
// Quick-EV drags write colorMatrixSync from JS on every tick; a torn
|
||||
// read can throw inside MakeMatrix (or yield NaN -> black surface) for
|
||||
// one frame. Validate + try/catch, fall back to identity (no filter):
|
||||
// a 1-frame gain error beats a 1-frame black beat. DEBUG-EV.
|
||||
const paint = Skia.Paint();
|
||||
paint.setColorFilter(Skia.ColorFilter.MakeMatrix(colorMatrixSync.getBlocking()));
|
||||
const tone = toneSync.getBlocking();
|
||||
const cinema = cinemaSync.getBlocking();
|
||||
try {
|
||||
const cm = colorMatrixSync.getDirty();
|
||||
if (cm != null && cm.length === 20 && cm.every((v) => typeof v === 'number' && Number.isFinite(v))) {
|
||||
paint.setColorFilter(Skia.ColorFilter.MakeMatrix(cm));
|
||||
} else {
|
||||
console.warn('Invalid color matrix (len ' + (cm == null ? 'null' : cm.length) + ') — filter skipped');
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('Color matrix build failed: ' + e);
|
||||
}
|
||||
const tone = toneSync.getDirty();
|
||||
const cinema = cinemaSync.getDirty();
|
||||
const hasTone = toneEffect != null && (tone[0] !== 0 || tone[1] !== 0 || tone[2] !== 0);
|
||||
const hasCinema = cinemaEffect != null && cinema[0] > 0;
|
||||
// Draws the full frame with the tone/cinema shader when active (drawRect,
|
||||
@@ -450,7 +577,7 @@ const Viewfinder = forwardRef<ViewfinderHandle, ViewfinderProps>(function Viewfi
|
||||
} catch (e) {
|
||||
if (!toneFailLogged.getBlocking()) {
|
||||
toneFailLogged.setBlocking(true);
|
||||
console.error('[camtone] shader failed, matrix-only: ' + e);
|
||||
console.error('Tone shader failed, matrix-only: ' + e);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -463,21 +590,12 @@ const Viewfinder = forwardRef<ViewfinderHandle, ViewfinderProps>(function Viewfi
|
||||
// (under the renderToTexture rotation/mirror CTM) and cover-map the
|
||||
// sensor there. Equal-aspect bands (ratio 4:3 == native 3:4 sensor)
|
||||
// then show the ENTIRE sensor; narrower bands crop like the export.
|
||||
const feed = feedRectSync.getBlocking();
|
||||
const feed = feedRectSync.getDirty();
|
||||
const ftW = frameTexture.width();
|
||||
const ftH = frameTexture.height();
|
||||
const orient = frame.orientation;
|
||||
const dbgMir = frame.isMirrored === true;
|
||||
const dbgCount = dbgCountSync.getBlocking();
|
||||
dbgCountSync.setBlocking(dbgCount + 1);
|
||||
const bandActive = feed[0] > 0;
|
||||
if (!bandActive) {
|
||||
// DEBUG-4x3 (remove): stream ground truth, no-band branch
|
||||
const line = `ft=${ftW}x${ftH} o=${orient} mir=${dbgMir} band=0 feed=[${feed.map((f) => f.toFixed(3)).join(',')}]`;
|
||||
if (dbgCount % 60 === 0 || line !== dbgLastSync.getBlocking()) {
|
||||
dbgLastSync.setBlocking(line);
|
||||
console.error('[camdebug] ' + line);
|
||||
}
|
||||
drawFullFrame();
|
||||
return;
|
||||
}
|
||||
@@ -549,12 +667,6 @@ const Viewfinder = forwardRef<ViewfinderHandle, ViewfinderProps>(function Viewfi
|
||||
const dh = s * ftH;
|
||||
const dx = u0 + (rw - dw) / 2;
|
||||
const dy = v0 + (rh - dh) / 2;
|
||||
// DEBUG-4x3 (remove): full geometry log for the band branch
|
||||
const line = `ft=${ftW}x${ftH} o=${orient} mir=${dbgMir} band=1 feed=[${feed.map((f) => f.toFixed(3)).join(',')}] up=${ow}x${oh} ac=${ac.toFixed(4)} vis=${visX.toFixed(3)}x${visY.toFixed(3)} X0=${X0.toFixed(0)} Y0=${Y0.toFixed(0)} X1=${X1.toFixed(0)} Y1=${Y1.toFixed(0)} raw=${u0.toFixed(0)},${v0.toFixed(0)} ${rw.toFixed(0)}x${rh.toFixed(0)} s=${s.toFixed(4)} d=${dx.toFixed(0)},${dy.toFixed(0)}`;
|
||||
if (dbgCount % 60 === 0 || line !== dbgLastSync.getBlocking()) {
|
||||
dbgLastSync.setBlocking(line);
|
||||
console.error('[camdebug] ' + line);
|
||||
}
|
||||
// Fresh paint; its default color is opaque black (Skia default), so
|
||||
// the surface fill below needs no explicit color set.
|
||||
const blackPaint = Skia.Paint();
|
||||
@@ -644,19 +756,6 @@ const Viewfinder = forwardRef<ViewfinderHandle, ViewfinderProps>(function Viewfi
|
||||
: [0, 0, 0, 0, 0, 0]
|
||||
);
|
||||
}, [cameraPhotoRect, vw, vh, feedRectSync]);
|
||||
// DEBUG-4x3 (remove): correlate JS view/region state with the worklet log.
|
||||
useEffect(() => {
|
||||
console.error(
|
||||
'[camdebug-js] region=' + JSON.stringify(region) +
|
||||
' band=' + regionBand +
|
||||
' ratio=' + aspectRatio +
|
||||
' frame=' + selectedFrame +
|
||||
' view=' + vw + 'x' + vh +
|
||||
' feed=' + (cameraPhotoRect ? JSON.stringify([1, vw / vh, cameraPhotoRect.x / vw, cameraPhotoRect.y / vh, cameraPhotoRect.w / vw, cameraPhotoRect.h / vh]) : 'null') +
|
||||
' frameTarget=' + (frameTarget ? frameTarget.width + 'x' + frameTarget.height : 'null')
|
||||
);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [region, regionBand, aspectRatio, selectedFrame, cameraPhotoRect]);
|
||||
const onLibTouchStart = (e: any) => {
|
||||
const ts = e.nativeEvent.touches;
|
||||
if (ts.length >= 2) {
|
||||
@@ -981,16 +1080,21 @@ const Viewfinder = forwardRef<ViewfinderHandle, ViewfinderProps>(function Viewfi
|
||||
outputs={cameraOutputs}
|
||||
pixelFormat="yuv"
|
||||
targetResolution={frameTarget}
|
||||
onStarted={() => setCameraActive(true)}
|
||||
onStarted={handleCameraStarted}
|
||||
onStopped={handleCameraStopped}
|
||||
onFrame={handleFrame}
|
||||
onError={onSessionError}
|
||||
/>
|
||||
{/* Tap-to-focus layer: fullscreen responder (all visual overlays above
|
||||
are pointerEvents none). locationX/Y are relative to this view. */}
|
||||
{/* Gesture layer: tap = AE/AF lock; vertical drag on the image adjusts
|
||||
the open panel parameter (or EV while AE is locked). Sits below the
|
||||
quick-EV strip, which keeps its own narrower direct-drag handle. */}
|
||||
<View
|
||||
className="absolute inset-0"
|
||||
onStartShouldSetResponder={() => true}
|
||||
onResponderGrant={(e) => handleTapFocus(e.nativeEvent.locationX, e.nativeEvent.locationY)}
|
||||
onResponderGrant={onImageDragGrant}
|
||||
onResponderMove={onImageDragMove}
|
||||
onResponderRelease={onImageDragEnd}
|
||||
onResponderTerminate={onImageDragEnd}
|
||||
/>
|
||||
{focusPoint && (
|
||||
<View
|
||||
|
||||
Reference in New Issue
Block a user