Add aspect-ratio composition band and RAW DNG sidecar capture
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user