Add aspect-ratio composition band and RAW DNG sidecar capture

This commit is contained in:
2026-09-08 18:43:02 +07:00
parent a16c8734a8
commit d43405a4de
6 changed files with 371 additions and 65 deletions
+117 -5
View File
@@ -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<FrameId>('none');
const [useGeotag, setUseGeotag] = useState(true);
const [gpsInfo, setGpsInfo] = useState<GPSInfo | null>(null);
// Composition aspect-ratio (FULL / 4:3 / 3:2 upright) + RAW DNG sidecar.
const [aspectRatio, setAspectRatio] = useState<AspectRatio>('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<TabId | null>(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<boolean> => {
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 })}
/>
</View>
@@ -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}
+44 -2
View File
@@ -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<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;
}
@@ -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({
</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;
}
+3 -2
View File
@@ -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) {
+143 -51
View File
@@ -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 (
<Group color="white">
<Rect x={0} y={0} width={vw} height={borderWidth} />
<Rect x={0} y={vh - borderWidth} width={vw} height={borderWidth} />
<Rect x={0} y={0} width={borderWidth} height={vh} />
<Rect x={vw - borderWidth} y={0} width={borderWidth} height={vh} />
<Rect x={region.x} y={region.y} width={region.w} height={borderWidth} />
<Rect x={region.x} y={region.y + region.h - borderWidth} width={region.w} height={borderWidth} />
<Rect x={region.x} y={region.y} width={borderWidth} height={region.h} />
<Rect x={region.x + region.w - borderWidth} y={region.y} width={borderWidth} height={region.h} />
</Group>
);
}
if (selectedFrame === 'cinematic') {
// Letterbox bars hug the composition region's top/bottom edges.
return (
<Group color="black">
<Rect x={0} y={0} width={vw} height={barHeight} />
<Rect x={0} y={vh - barHeight} width={vw} height={barHeight} />
<Rect x={region.x} y={region.y} width={region.w} height={barHeight} />
<Rect x={region.x} y={region.y + region.h - barHeight} width={region.w} height={barHeight} />
</Group>
);
}
@@ -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 (
<>
<Canvas style={StyleSheet.absoluteFill} pointerEvents="none">
{/* 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 && (
<Group color="#000000">
<Rect x={0} y={0} width={vw} height={region.y} />
<Rect x={0} y={region.y + region.h} width={vw} height={vh - region.y - region.h} />
<Rect x={0} y={region.y} width={region.x} height={region.h} />
<Rect x={region.x + region.w} y={region.y} width={vw - region.x - region.w} height={region.h} />
</Group>
)}
{grainOpacity > 0 && noiseEffect && (
<Group blendMode="overlay">
{/* Grain belongs to the photo; on a framed photo it must not spill
onto the mat, so it is clipped to the photo window. */}
<Rect
x={framedWin ? framedWin.x : 0}
y={framedWin ? framedWin.y : 0}
width={framedWin ? framedWin.w : vw}
height={framedWin ? framedWin.h : vh}
x={framedWin ? framedWin.x : region.x}
y={framedWin ? framedWin.y : region.y}
width={framedWin ? framedWin.w : region.w}
height={framedWin ? framedWin.h : region.h}
opacity={grainOpacity}
>
<Shader source={noiseEffect} />
@@ -668,13 +739,17 @@ export default function Viewfinder({
{renderGPSWatermark()}
</Canvas>
<View className="absolute inset-0 flex-row justify-between pointer-events-none opacity-20">
<View className="w-[1px] h-full bg-white ml-[33%]" />
<View className="w-[1px] h-full bg-white mr-[33%]" />
</View>
<View className="absolute inset-0 flex-col justify-between pointer-events-none opacity-20">
<View className="h-[1px] w-full bg-white mt-[33%]" />
<View className="h-[1px] w-full bg-white mb-[33%]" />
{/* Rule-of-thirds guide, clipped to the composition region (fullscreen
when no ratio band). */}
<View
pointerEvents="none"
className="absolute opacity-20 overflow-hidden"
style={{ left: region.x, top: region.y, width: region.w, height: region.h }}
>
<View style={{ position: 'absolute', left: region.w / 3, top: 0, bottom: 0, width: 1, backgroundColor: '#ffffff' }} />
<View style={{ position: 'absolute', right: region.w / 3, top: 0, bottom: 0, width: 1, backgroundColor: '#ffffff' }} />
<View style={{ position: 'absolute', top: region.h / 3, left: 0, right: 0, height: 1, backgroundColor: '#ffffff' }} />
<View style={{ position: 'absolute', bottom: region.h / 3, left: 0, right: 0, height: 1, backgroundColor: '#ffffff' }} />
</View>
</>
);
@@ -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] }}
>
<ImageShader image={skiaImage} fit="fill" tx="clamp" ty="clamp" rect={winRect} />
<ImageShader image={skiaImage} fit={polaroidFit} tx="clamp" ty="clamp" rect={winRect} />
</Shader>
) : (
<ImageShader image={skiaImage} fit="fill" tx="clamp" ty="clamp" rect={winRect} />
<ImageShader image={skiaImage} fit={polaroidFit} tx="clamp" ty="clamp" rect={winRect} />
)}
</Shader>
) : toneEffect && toneOn ? (
@@ -828,12 +912,12 @@ export default function Viewfinder({
source={toneEffect}
uniforms={{ dr: toneParams[0], hl: toneParams[1], sh: toneParams[2] }}
>
<ImageShader image={skiaImage} fit="fill" tx="clamp" ty="clamp" rect={winRect} />
<ImageShader image={skiaImage} fit={polaroidFit} tx="clamp" ty="clamp" rect={winRect} />
</Shader>
) : null}
</Rect>
) : (
<SkiaImage image={skiaImage} fit="fill" rect={winRect} />
<SkiaImage image={skiaImage} fit={polaroidFit} rect={winRect} />
)}
</Group>
{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.
<Rect
x={imageFitRect!.dx}
y={imageFitRect!.dy}
width={imageFitRect!.dw}
height={imageFitRect!.dh}
x={dispRect!.dx}
y={dispRect!.dy}
width={dispRect!.dw}
height={dispRect!.dh}
>
{cinemaOn ? (
<Shader source={cinemaEffect!} uniforms={cinemaParams!.uniforms}>
@@ -951,19 +1035,19 @@ export default function Viewfinder({
>
<ImageShader
image={skiaImage}
fit="fill"
fit={dispFit}
tx="clamp"
ty="clamp"
rect={rect(imageFitRect!.dx, imageFitRect!.dy, imageFitRect!.dw, imageFitRect!.dh)}
rect={rect(dispRect!.dx, dispRect!.dy, dispRect!.dw, dispRect!.dh)}
/>
</Shader>
) : (
<ImageShader
image={skiaImage}
fit="fill"
fit={dispFit}
tx="clamp"
ty="clamp"
rect={rect(imageFitRect!.dx, imageFitRect!.dy, imageFitRect!.dw, imageFitRect!.dh)}
rect={rect(dispRect!.dx, dispRect!.dy, dispRect!.dw, dispRect!.dh)}
/>
)}
</Shader>
@@ -974,21 +1058,29 @@ export default function Viewfinder({
>
<ImageShader
image={skiaImage}
fit="fill"
fit={dispFit}
tx="clamp"
ty="clamp"
rect={rect(imageFitRect!.dx, imageFitRect!.dy, imageFitRect!.dw, imageFitRect!.dh)}
rect={rect(dispRect!.dx, dispRect!.dy, dispRect!.dw, dispRect!.dh)}
/>
</Shader>
) : null}
</Rect>
) : dispRect ? (
<SkiaImage
image={skiaImage}
fit={dispFit}
rect={rect(dispRect.dx, dispRect.dy, dispRect.dw, dispRect.dh)}
/>
) : (
<SkiaImage image={skiaImage} fit="contain" rect={rect(0, 0, vw, vh)} />
)}
</Group>
{grainOpacity > 0 && noiseEffect && (
<Group blendMode="overlay">
<Rect x={0} y={0} width={vw} height={vh} opacity={grainOpacity}>
{/* Grain covers the whole photo area — the composition region
under a ratio band, the full screen otherwise. */}
<Rect x={region.x} y={region.y} width={region.w} height={region.h} opacity={grainOpacity}>
<Shader source={noiseEffect} />
</Rect>
</Group>
+11
View File
@@ -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<Exclude<AspectRatio, 'full'>, 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.
+53 -5
View File
@@ -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) {