Ship LITE and PRO, and let CLARITY and LIGHT GLOW reach the library
Both flavors build from one JS tree: `lite` hands out the free apk (com.locphamtran.recipescamera), `pro` sells as-is (same id + .pro, label "RecipesCam Pro"). Release is signed from android/keystore.properties, both files gitignored - lose either and no later build can install over this one. The tier reaches JS through src/provariant.ts, which tools/set-variant.mjs rewrites before each build (`npm run apk:lite` / `apk:pro`); the gradle flavor alone would ship a Pro apk that thinks it is Lite. The dev scaffolding goes: the three src/dev probes and their App effects, the EXPO_PUBLIC_CAPTURE_MODE [CAPTURE] timing log, and the -PdevtestSuffix side-by-side install hook. 'balanced' stays the shipped capture mode, which is what those probes measured on the 12S Ultra. Looking at the picture: WB now converts kelvin through the Planckian locus into a luma-normalised gain (unity at 5500K, symmetric tint) instead of the old one-sided push; LEITZ STREETLIFE splits into CLASSIC and VIVID; selecting a recipe loads every knob it carries over the defaults and stays editable, and RESET hands the panel back to the sim's own values. The in-app library picker, the ultra-wide native module and recipe share/import land here too. CLARITY and LIGHT GLOW existed only on the camera worklet and the export engine, so a photo opened from the library ignored both. They now run in all three library preview branches (libPhoto), and the preview clips them to the drawn image - the export engine always clipped its bloom to the canvas, the preview drew it over the whole rect, so the bloom bled past the photo onto the black letterbox and over the frame's mat.
This commit is contained in:
@@ -108,6 +108,9 @@ interface AdjustmentPanelProps {
|
||||
onToggleWallLandscape: () => void;
|
||||
onToggleGeotag: (enabled: boolean) => void;
|
||||
onSaveRecipe: (name: string) => void;
|
||||
// IMPORT: pick a recipe file shared from another phone and store it as a
|
||||
// normal custom recipe.
|
||||
onImportRecipe: () => void;
|
||||
onDeleteRecipe: (id: string) => void;
|
||||
// WATERMARK tab state — lifted to App so the live Viewfinder overlay, this
|
||||
// input and the export options all read the same object.
|
||||
@@ -171,7 +174,7 @@ const WB_PAIRS: Record<string, { t: number; ti: number }> = {
|
||||
daylight: { t: 5500, ti: 0 },
|
||||
'daylight-3r': { t: 5500, ti: -3 },
|
||||
cloudy: { t: 6500, ti: 1 },
|
||||
shade: { t: 8000, ti: 2 },
|
||||
shade: { t: 7500, ti: 2 },
|
||||
tungsten: { t: 3200, ti: 0 },
|
||||
fluorescent: { t: 4000, ti: 3 },
|
||||
};
|
||||
@@ -239,6 +242,7 @@ export default function AdjustmentPanel({
|
||||
wallLandscape,
|
||||
onToggleWallLandscape,
|
||||
onToggleGeotag,
|
||||
onImportRecipe,
|
||||
onDeleteRecipe,
|
||||
customWm,
|
||||
onUpdateCustomWm,
|
||||
@@ -791,6 +795,12 @@ export default function AdjustmentPanel({
|
||||
active: false,
|
||||
onPress: () => setCreateVisible(true),
|
||||
},
|
||||
{
|
||||
key: 'import',
|
||||
label: 'IMPORT',
|
||||
active: false,
|
||||
onPress: onImportRecipe,
|
||||
},
|
||||
],
|
||||
8
|
||||
);
|
||||
@@ -1024,6 +1034,7 @@ export default function AdjustmentPanel({
|
||||
<RecipeCreateModal
|
||||
visible={createVisible || !!editingRecipe}
|
||||
currentBase={currentBaseFilter}
|
||||
current={adjustments}
|
||||
initial={editingRecipe ?? null}
|
||||
onClose={() => {
|
||||
setCreateVisible(false);
|
||||
|
||||
@@ -6,6 +6,10 @@ import { Image as ImageIcon, Eye, Download, Camera, Settings, RefreshCw } from '
|
||||
interface CameraControlsProps {
|
||||
lastPhotoUri: string | null;
|
||||
onCapture: () => void;
|
||||
// Held shutter = burst: fire on long-press, stop when the finger lifts.
|
||||
// Absent handlers (library mode) leave the button a plain tap.
|
||||
onBurstStart?: () => void;
|
||||
onBurstStop?: () => void;
|
||||
onPickImage: () => void;
|
||||
onOpenPreview: () => void;
|
||||
onOpenSettings: () => void;
|
||||
@@ -17,6 +21,8 @@ interface CameraControlsProps {
|
||||
export default function CameraControls({
|
||||
lastPhotoUri,
|
||||
onCapture,
|
||||
onBurstStart,
|
||||
onBurstStop,
|
||||
onPickImage,
|
||||
onOpenPreview,
|
||||
onOpenSettings,
|
||||
@@ -55,6 +61,11 @@ export default function CameraControls({
|
||||
{/* Dynamic Center Button: Shutter or Save */}
|
||||
<TouchableOpacity
|
||||
onPress={onCapture}
|
||||
// Pressability drops onPress once onLongPress has fired, so releasing a
|
||||
// hold never appends a stray single shot after the burst.
|
||||
onLongPress={onBurstStart}
|
||||
onPressOut={onBurstStop}
|
||||
delayLongPress={300}
|
||||
className="relative items-center justify-center"
|
||||
activeOpacity={0.8}
|
||||
>
|
||||
|
||||
@@ -26,6 +26,10 @@ interface RecipeCreateModalProps {
|
||||
// Id of the currently applied film sim (or bundled recipe whose look should
|
||||
// prefill the SIMULATION row); undefined → default to PROVIPES.
|
||||
currentBase?: BaseFilter;
|
||||
// The look live on screen. CREATE opens on THESE values (not on the sim's
|
||||
// standard ones) so a recipe is a snapshot of what the user is looking at;
|
||||
// every knob the form has no row for rides along on SAVE.
|
||||
current?: ColorAdjustments | null;
|
||||
// When set the form opens as an EDITOR: every row (name included) is
|
||||
// prefilled from this recipe instead of the sim's standard values, and
|
||||
// SAVE hands the edited copy back for the caller to store.
|
||||
@@ -144,16 +148,24 @@ const NumRow = ({ label, value, onChange, placeholder, width = 58 }: NumRowProps
|
||||
export default function RecipeCreateModal({
|
||||
visible,
|
||||
currentBase,
|
||||
current,
|
||||
initial,
|
||||
onClose,
|
||||
onSave,
|
||||
}: RecipeCreateModalProps) {
|
||||
const insets = useSafeAreaInsets();
|
||||
// Seed from the current film sim (or PROVIPES) so "other params" start at the
|
||||
// sim's standard values and the user only types what they want to change.
|
||||
// Seed from what the user is LOOKING AT: the recipe being edited, else the live
|
||||
// adjustments, else the current sim's standard values. Seeding from the sim's
|
||||
// standard values alone (the old behaviour) meant the form showed — and SAVE
|
||||
// wrote — neutral numbers for knobs the user had already dialled in.
|
||||
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;
|
||||
initial?.baseFilter ??
|
||||
(currentBase && FILM_SIMS.some((s) => s.baseFilter === currentBase) ? currentBase : 'provia');
|
||||
const seedAdj: ColorAdjustments =
|
||||
initial?.adjustments ??
|
||||
current ??
|
||||
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.
|
||||
@@ -161,13 +173,19 @@ export default function RecipeCreateModal({
|
||||
const s = src ?? 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),
|
||||
// Raw, not rounded to the three chips: a look sitting on grain 2 must not
|
||||
// become 3 just because the form was opened (and the chip row below shows
|
||||
// that value as its own chip when it is none of OFF/WEAK/STRONG).
|
||||
grain: String(s.grain ?? 0),
|
||||
cc: s.colorChrome ?? 'none',
|
||||
ccb: s.colorChromeBlue ?? 'none',
|
||||
// 'custom' = a WB the six chips do not name (a hand-set 6300K, say). It
|
||||
// keeps its own chip and its own value; the old AUTO fallback rewrote the
|
||||
// recipe's WB to 5500K on every save.
|
||||
wbKey: WB_KEYS.find((k) => {
|
||||
const p = WB_PRESETS.find((w) => w.key === k)!;
|
||||
return p.temperature === s.temperature && p.tint === s.tint;
|
||||
}) ?? 'auto',
|
||||
}) ?? 'custom',
|
||||
highlight: String(s.highlight),
|
||||
shadow: String(s.shadow),
|
||||
color: String(s.saturation),
|
||||
@@ -181,14 +199,14 @@ export default function RecipeCreateModal({
|
||||
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 [grain, setGrain] = useState<string>(String(seedAdj.grain ?? 0));
|
||||
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'
|
||||
}) ?? 'custom'
|
||||
);
|
||||
const [wbRed, setWbRed] = useState('0');
|
||||
const [wbBlue, setWbBlue] = useState('0');
|
||||
@@ -208,15 +226,15 @@ export default function RecipeCreateModal({
|
||||
// Editing a stored recipe wins over the live sim seed: the form must show
|
||||
// that recipe's own values, not the sim's standard ones.
|
||||
const base = initial?.baseFilter ?? seedBase;
|
||||
const v = valuesOf(base, initial?.adjustments);
|
||||
const v = valuesOf(base, seedAdj);
|
||||
setSim(base);
|
||||
setDr(v.dr);
|
||||
setGrain(v.grain);
|
||||
setCc(v.cc);
|
||||
setCcb(v.ccb);
|
||||
setWbKey(v.wbKey);
|
||||
setWbRed(String(initial?.adjustments.wbRed ?? 0));
|
||||
setWbBlue(String(initial?.adjustments.wbBlue ?? 0));
|
||||
setWbRed(String(seedAdj.wbRed ?? 0));
|
||||
setWbBlue(String(seedAdj.wbBlue ?? 0));
|
||||
setFHigh(v.highlight);
|
||||
setFShadow(v.shadow);
|
||||
setFColor(v.color);
|
||||
@@ -250,13 +268,19 @@ export default function RecipeCreateModal({
|
||||
};
|
||||
|
||||
const handleSave = () => {
|
||||
const wb = WB_PRESETS.find((w) => w.key === wbKey) ?? WB_PRESETS[0];
|
||||
const wb = WB_PRESETS.find((w) => w.key === wbKey);
|
||||
const simAdj = FILM_SIMS.find((s) => s.baseFilter === sim)?.adjustments ?? DEFAULT_ADJUSTMENTS;
|
||||
// The base is what the user was looking at (CREATE) or the recipe being
|
||||
// edited. Spreading the sim's neutral values here instead — the old
|
||||
// behaviour — silently zeroed every knob the form has no row for: exposure,
|
||||
// contrast, vibrance, vignetting, glow, the WB red/blue shift. That is what
|
||||
// made a saved recipe apply a different look from the one on screen.
|
||||
const adj: ColorAdjustments = {
|
||||
...DEFAULT_ADJUSTMENTS,
|
||||
...simAdj,
|
||||
temperature: wb.temperature,
|
||||
tint: wb.tint,
|
||||
...seedAdj,
|
||||
// A WB with no preset of its own keeps the value it was seeded with.
|
||||
temperature: wb ? wb.temperature : seedAdj.temperature,
|
||||
tint: wb ? wb.tint : seedAdj.tint,
|
||||
wbRed: int(wbRed, 0, -9, 9),
|
||||
wbBlue: int(wbBlue, 0, -9, 9),
|
||||
highlight: int(fHigh, simAdj.highlight, -10, 10),
|
||||
@@ -265,7 +289,7 @@ export default function RecipeCreateModal({
|
||||
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),
|
||||
grain: int(grain, seedAdj.grain, 0, 10),
|
||||
colorChrome: cc as ColorAdjustments['colorChrome'],
|
||||
colorChromeBlue: ccb as ColorAdjustments['colorChromeBlue'],
|
||||
dynamicRange: (dr === 'auto' ? 'auto' : parseInt(dr, 10)) as ColorAdjustments['dynamicRange'],
|
||||
@@ -354,6 +378,9 @@ export default function RecipeCreateModal({
|
||||
{GRAIN_OPTS.map((o) => (
|
||||
<Chip key={o.v} label={o.d} active={grain === o.v} onPress={() => setGrain(o.v)} />
|
||||
))}
|
||||
{!GRAIN_OPTS.some((o) => o.v === grain) && (
|
||||
<Chip label={grain} active onPress={() => {}} />
|
||||
)}
|
||||
</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>
|
||||
@@ -375,6 +402,9 @@ export default function RecipeCreateModal({
|
||||
{WB_PRESETS.map((w) => (
|
||||
<Chip key={w.key} label={w.label} active={wbKey === w.key} onPress={() => setWbKey(w.key)} />
|
||||
))}
|
||||
{wbKey === 'custom' && (
|
||||
<Chip label={`${seedAdj.temperature}K`} active onPress={() => setWbKey('custom')} />
|
||||
)}
|
||||
</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">
|
||||
|
||||
@@ -31,6 +31,8 @@ interface SettingsModalProps {
|
||||
onMeterModeChange: (mode: MeterMode) => void;
|
||||
shutterSound: ShutterSound;
|
||||
onShutterSound: (sound: ShutterSound) => void;
|
||||
touchToShutter: boolean;
|
||||
onTouchToShutter: (enabled: boolean) => void;
|
||||
gpsEnabled: boolean;
|
||||
onGpsEnabled: (enabled: boolean) => void;
|
||||
startupMode: StartupMode;
|
||||
@@ -95,6 +97,8 @@ export default function SettingsModal({
|
||||
onMeterModeChange,
|
||||
shutterSound,
|
||||
onShutterSound,
|
||||
touchToShutter,
|
||||
onTouchToShutter,
|
||||
gpsEnabled,
|
||||
onGpsEnabled,
|
||||
startupMode,
|
||||
@@ -200,6 +204,26 @@ export default function SettingsModal({
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Touch to shutter */}
|
||||
<View>
|
||||
<SectionLabel>TOUCH TO SHUTTER</SectionLabel>
|
||||
<ChipRow
|
||||
chips={[
|
||||
cameraMode
|
||||
? { key: 'tts-on', label: 'ON', active: touchToShutter, onPress: () => onTouchToShutter(true) }
|
||||
: { key: 'tts-on', label: 'ON', active: touchToShutter, disabled: true, onPress: () => {} },
|
||||
cameraMode
|
||||
? { key: 'tts-off', label: 'OFF', active: !touchToShutter, onPress: () => onTouchToShutter(false) }
|
||||
: { key: 'tts-off', label: 'OFF', active: !touchToShutter, disabled: true, onPress: () => {} },
|
||||
]}
|
||||
/>
|
||||
<Text className="text-zinc-600 font-mono text-[10px] mt-1.5 ml-1">
|
||||
Tap the live image to lock focus on that point, then take the photo the moment focus settles.
|
||||
OFF: taps only focus.
|
||||
</Text>
|
||||
<CameraOnlyNote cameraMode={cameraMode} />
|
||||
</View>
|
||||
|
||||
{/* Composition ratio + RAW */}
|
||||
<View>
|
||||
<SectionLabel>PHOTO RATIO</SectionLabel>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { useState } from 'react';
|
||||
import { View, Text, TouchableOpacity, TextInput, Modal, KeyboardAvoidingView, Platform } from 'react-native';
|
||||
import { View, Text, TouchableOpacity, TextInput, Modal, KeyboardAvoidingView, Platform, Alert } from 'react-native';
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
import { Plus, Pencil } from 'lucide-react-native';
|
||||
import * as Haptics from 'expo-haptics';
|
||||
@@ -13,9 +13,12 @@ interface TopBarProps {
|
||||
// Also custom-only: opens the recipe editor — the CREATE form prefilled
|
||||
// with this recipe's parameters and name.
|
||||
onEditRecipe?: () => void;
|
||||
// SAVE & SHARE -> EXPORT: write this look to an encrypted recipe file and
|
||||
// hand it to the system share sheet so it can reach another phone.
|
||||
onExportShare: (name: string) => void;
|
||||
}
|
||||
|
||||
export default function TopBar({ mode, recipeName, onSaveRecipe, onOverwriteRecipe, onEditRecipe }: TopBarProps) {
|
||||
export default function TopBar({ mode, recipeName, onSaveRecipe, onOverwriteRecipe, onEditRecipe, onExportShare }: TopBarProps) {
|
||||
const insets = useSafeAreaInsets();
|
||||
const [modalVisible, setModalVisible] = useState(false);
|
||||
const [newRecipeName, setNewRecipeName] = useState('');
|
||||
@@ -27,6 +30,20 @@ export default function TopBar({ mode, recipeName, onSaveRecipe, onOverwriteReci
|
||||
setModalVisible(true);
|
||||
};
|
||||
|
||||
// SAVE & SHARE: two ways out of the same look — keep it as a recipe on this
|
||||
// phone, or export it as a file another phone can import.
|
||||
const openSaveMenu = () => {
|
||||
Alert.alert(
|
||||
'SAVE & SHARE',
|
||||
'Save the look on this screen as a recipe, or export it as a file to share.',
|
||||
[
|
||||
{ text: 'CANCEL', style: 'cancel' },
|
||||
{ text: 'EXPORT & SHARE', onPress: () => onExportShare(recipeName) },
|
||||
{ text: 'SAVE RECIPE', onPress: openModal },
|
||||
],
|
||||
);
|
||||
};
|
||||
|
||||
const run = (fn: (name: string) => void) => {
|
||||
const name = newRecipeName.trim();
|
||||
if (!name) return;
|
||||
@@ -68,13 +85,13 @@ export default function TopBar({ mode, recipeName, onSaveRecipe, onOverwriteReci
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
<TouchableOpacity
|
||||
onPress={openModal}
|
||||
className="flex-row items-center rounded-full border border-zinc-700/80 bg-black/45 px-4 py-2.5"
|
||||
onPress={openSaveMenu}
|
||||
className="flex-row items-center rounded-full border border-zinc-700/80 bg-black/45 px-3 py-2.5"
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<Plus size={14} color="#f59e0b" />
|
||||
<Text className="ml-1 font-mono text-xs font-bold tracking-widest text-amber-500">
|
||||
SAVE
|
||||
<Text className="ml-1 font-mono text-[10px] font-bold tracking-wider text-amber-500">
|
||||
SAVE & SHARE
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
+399
-162
@@ -4,6 +4,7 @@ import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
import { activateKeepAwake, deactivateKeepAwake } from 'expo-keep-awake';
|
||||
import { useCameraDevice, type CameraPhotoOutput } from 'react-native-vision-camera';
|
||||
import { SkiaCamera, type SkiaCameraRef } from 'react-native-vision-camera-skia';
|
||||
import UltraWide, { UltraWidePreview } from '../../modules/recipescam-ultrawide';
|
||||
import { createSynchronizable } from 'react-native-worklets';
|
||||
import {
|
||||
Canvas,
|
||||
@@ -18,6 +19,7 @@ import {
|
||||
Rect,
|
||||
RadialGradient,
|
||||
ColorMatrix,
|
||||
Blur,
|
||||
Image as SkiaImage,
|
||||
ImageShader,
|
||||
BlendMode,
|
||||
@@ -29,7 +31,7 @@ import {
|
||||
import { Lock } from 'lucide-react-native';
|
||||
import { Recipe, GPSInfo, FrameId, AspectRatio, ColorAdjustments, ASPECT_RATIO_W_H, CropRatio, CropRect, CROP_W_H, DEFAULT_CROP_RECT, MIN_CROP_FRAC } from '../types';
|
||||
import { getSkiaColorMatrix, applyExposureGain } from '../utils/colorUtils';
|
||||
import { TONE_SKSL, getToneUniforms, toneUniformArray } from '../utils/toneShader';
|
||||
import { TONE_SKSL, getToneUniforms, toneUniformArray, GLOW_SKSL, glowUniformArray, GLOW_UNIFORMS, CLARITY_SKSL, clarityUniforms } from '../utils/toneShader';
|
||||
import { CINEMA_SKSL, getCinemaUniforms, cinemaOffFlat } from '../utils/cinemaShader';
|
||||
import { polaroidLayout, wallframeLayout, WALLFRAME_PNG_W, WALLFRAME_PNG_H } from '../utils/frameUtils';
|
||||
import { applyPhotoRotation } from '../utils/skiaImage';
|
||||
@@ -111,6 +113,9 @@ interface ViewfinderProps {
|
||||
// undone; the finger coming off asks for the edited look back, so the user
|
||||
// can flick between the before and the after. Absent prop = feature off.
|
||||
onComparePeek?: (on: boolean) => void;
|
||||
// Touch-to-shutter (gear sheet): a plain tap focuses as always, then fires a
|
||||
// capture once the focus promise settles. Absent prop = tap only focuses.
|
||||
onTouchShutter?: () => void;
|
||||
// Custom text watermark (WATERMARK tab -> CUSTOM chip). Drawn live on the
|
||||
// image like the GPS mark; dragging/tapping the image moves it. Fractions are
|
||||
// 0..1 of the photo/window area. Absent prop = feature off.
|
||||
@@ -157,6 +162,12 @@ export interface ViewfinderHandle {
|
||||
* exactly the crop the user framed after pinch/drag.
|
||||
*/
|
||||
getFrameWindowZoom(): { s: number; u: number; v: number } | null;
|
||||
/**
|
||||
* True while the 0.5x chip holds the native ultra-wide lens. The CameraX
|
||||
* session is off then, so App must capture through the native module instead
|
||||
* of `photoOutput`.
|
||||
*/
|
||||
isUltraWide(): boolean;
|
||||
/**
|
||||
* FRAME-tab CROP with a fixed ratio: the part of the photo the on-screen
|
||||
* keep-band shows, as 0..1 fractions of the (post-rotation) source image, so
|
||||
@@ -205,6 +216,29 @@ const cinemaEffect = (() => {
|
||||
}
|
||||
})();
|
||||
|
||||
// Bright Pass Filter effect for the HDF glow (GLOW_SKSL) — hoisted with the
|
||||
// two above so the preview worklet can capture it instead of compiling per
|
||||
// frame.
|
||||
const glowEffect = (() => {
|
||||
try {
|
||||
return Skia.RuntimeEffect.Make(GLOW_SKSL);
|
||||
} catch (e) {
|
||||
console.error('GLOW_SKSL compile failed: ' + e);
|
||||
return null;
|
||||
}
|
||||
})();
|
||||
|
||||
// Unsharp twin of the camera worklet's MakeMatrixConvolution (see CLARITY_SKSL)
|
||||
// for the library preview, which draws through the declarative tree instead.
|
||||
const clarityEffect = (() => {
|
||||
try {
|
||||
return Skia.RuntimeEffect.Make(CLARITY_SKSL);
|
||||
} catch (e) {
|
||||
console.error('CLARITY_SKSL compile failed: ' + e);
|
||||
return null;
|
||||
}
|
||||
})();
|
||||
|
||||
// Plain numeric primitives (captured by value into the preview worklet — the
|
||||
// worklet runtime can't read RNSkia's enum namespace objects directly).
|
||||
const T_CLAMP = TileMode.Clamp;
|
||||
@@ -237,6 +271,7 @@ const Viewfinder = forwardRef<ViewfinderHandle, ViewfinderProps>(function Viewfi
|
||||
meteringAdjustments,
|
||||
imageAdjustTarget,
|
||||
onComparePeek,
|
||||
onTouchShutter,
|
||||
customWm = { enabled: false, text: '', x: 0.5, y: 0.5 },
|
||||
onWmPositionChange,
|
||||
gpsWm,
|
||||
@@ -426,12 +461,20 @@ const Viewfinder = forwardRef<ViewfinderHandle, ViewfinderProps>(function Viewfi
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[]
|
||||
);
|
||||
// Clarity (-10..10): local unsharp mask / mist, the same numbers exportEngine
|
||||
// uses, so the live preview and the exported file agree. 0 = no filter.
|
||||
const claritySync = useMemo(
|
||||
() => createSynchronizable<number>(adjustments.clarity ?? 0),
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[]
|
||||
);
|
||||
useEffect(() => {
|
||||
colorMatrixSync.setBlocking(colorMatrix);
|
||||
toneSync.setBlocking(toneParams);
|
||||
cinemaSync.setBlocking(cinemaParams ? cinemaParams.flat : cinemaOffFlat());
|
||||
hdfSync.setBlocking(adjustments.hdf ?? 0);
|
||||
}, [colorMatrix, toneParams, cinemaParams, adjustments.hdf, colorMatrixSync, toneSync, cinemaSync, hdfSync]);
|
||||
claritySync.setBlocking(adjustments.clarity ?? 0);
|
||||
}, [colorMatrix, toneParams, cinemaParams, adjustments.hdf, adjustments.clarity, colorMatrixSync, toneSync, cinemaSync, hdfSync, claritySync]);
|
||||
|
||||
// Camera controller lives behind SkiaCamera. The camera AE bias is never
|
||||
// written: EV (user + metering offset) is purely software matrix gain, for
|
||||
@@ -500,13 +543,123 @@ const Viewfinder = forwardRef<ViewfinderHandle, ViewfinderProps>(function Viewfi
|
||||
useEffect(() => {
|
||||
zoomRef.current = zoomRatio;
|
||||
}, [zoomRatio]);
|
||||
const applyZoom = useCallback((ratio: number) => {
|
||||
setZoomRatio(ratio);
|
||||
const ctl = skiaCameraRef.current?.controller;
|
||||
if (ctl) {
|
||||
ctl.setZoom(ratio).catch((e) => console.warn('setZoom failed', String(e).slice(0, 120)));
|
||||
}
|
||||
// The back ultra-wide lens is a hidden Camera2 id (see the native module), so
|
||||
// 0.5x is not a CameraX zoom ratio: the chip swaps the live preview over to a
|
||||
// native Camera2 session on that lens. Only the back side has one.
|
||||
const [ultraWideId, setUltraWideId] = useState('');
|
||||
const [ultraWideOn, setUltraWideOn] = useState(false);
|
||||
const ultraWideOnRef = useRef(false);
|
||||
ultraWideOnRef.current = ultraWideOn;
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
UltraWide.availableAsync()
|
||||
.then((id) => {
|
||||
if (alive) setUltraWideId(id || '');
|
||||
})
|
||||
.catch((e) => console.warn('Ultra-wide probe failed', String(e).slice(0, 120)));
|
||||
return () => {
|
||||
alive = false;
|
||||
};
|
||||
}, []);
|
||||
const ultraWideReady = ultraWideId !== '' && facing === 'back';
|
||||
const ultraWideReadyRef = useRef(false);
|
||||
ultraWideReadyRef.current = ultraWideReady;
|
||||
// Handoff cover. The two back lenses cannot be open at once, so leaving 0.5x
|
||||
// closes the native session first and the 1x preview only then reopens: for
|
||||
// ~1s the viewfinder shows a frozen stale frame and then the reopening
|
||||
// session's out-of-focus, shifted first frames. The ultra-wide keeps drawing
|
||||
// its own last frame over that instead (the native `cover` prop), so the swap
|
||||
// reads as one clean cut from 0.5x to 1x.
|
||||
const [uwCover, setUwCover] = useState(false);
|
||||
const uwCoverTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const uwFramePoll = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
const stopCoverTimer = useCallback(() => {
|
||||
if (uwCoverTimer.current) clearTimeout(uwCoverTimer.current);
|
||||
uwCoverTimer.current = null;
|
||||
if (uwFramePoll.current) clearInterval(uwFramePoll.current);
|
||||
uwFramePoll.current = null;
|
||||
}, []);
|
||||
const holdCover = useCallback(
|
||||
(ms?: number) => {
|
||||
stopCoverTimer();
|
||||
if (ms == null) {
|
||||
setUwCover(true);
|
||||
return;
|
||||
}
|
||||
uwCoverTimer.current = setTimeout(() => {
|
||||
uwCoverTimer.current = null;
|
||||
setUwCover(false);
|
||||
}, ms);
|
||||
},
|
||||
[stopCoverTimer]
|
||||
);
|
||||
useEffect(() => stopCoverTimer, [stopCoverTimer]);
|
||||
// Bumped on every lens handoff: a slow release belonging to an older one must
|
||||
// not touch the state a newer zoom choice already owns.
|
||||
const uwHandoff = useRef(0);
|
||||
const applyZoom = useCallback(
|
||||
(ratio: number) => {
|
||||
const native = ratio === 0.5 && ultraWideReadyRef.current;
|
||||
// The reticle points at a region of a lens that is about to go away.
|
||||
if (native !== ultraWideOnRef.current) setFocusPoint(null);
|
||||
setZoomRatio(ratio);
|
||||
if (!native && ultraWideOnRef.current) {
|
||||
// Hold the outgoing 0.5x frame until the main preview really paints
|
||||
// again, instead of a fixed 700ms: measured on this device the fresh
|
||||
// 1x session lands anywhere from 0.2s to 0.9s after the tap, so the
|
||||
// old ceiling left a black hole when it ran slow and a stale frame
|
||||
// when it ran fast. The frame beat advances on every delivered frame
|
||||
// of the main camera, so it is the honest "1x is live" signal.
|
||||
holdCover(1500); // ceiling: never keep the stale frame up for good
|
||||
const beatAtSwap = frameBeatSync.getDirty();
|
||||
uwFramePoll.current = setInterval(() => {
|
||||
if (frameBeatSync.getDirty() - beatAtSwap < 3) return;
|
||||
stopCoverTimer();
|
||||
setUwCover(false);
|
||||
}, 50);
|
||||
// The main session is only switched back on once the native lens has
|
||||
// actually let go of the back camera slot. Activating it earlier makes
|
||||
// CameraX open the 1x while the HAL still holds that lens, and the
|
||||
// refused open is retried on a fixed ~520ms timeout - the bulk of the
|
||||
// handoff time.
|
||||
const handoff = ++uwHandoff.current;
|
||||
UltraWide.closeAsync()
|
||||
.catch(() => {})
|
||||
.then(() => {
|
||||
if (uwHandoff.current !== handoff) return;
|
||||
setUltraWideOn(false);
|
||||
});
|
||||
} else {
|
||||
uwHandoff.current++; // a release still in flight belongs to the past now
|
||||
setUltraWideOn(native);
|
||||
if (native) {
|
||||
holdCover();
|
||||
// Back to 0.5x while the previous handoff was still releasing the
|
||||
// lens: it is closed by now, so ask for it back.
|
||||
if (ultraWideOnRef.current) UltraWide.reopenAsync().catch(() => {});
|
||||
}
|
||||
}
|
||||
// The native lens is a different camera: the main session is off while
|
||||
// it is live, so never carry the 0.5 over as a zoom ratio onto it.
|
||||
zoomRef.current = native ? 1 : ratio;
|
||||
const ctl = skiaCameraRef.current?.controller;
|
||||
if (ctl && !native) {
|
||||
ctl.setZoom(ratio).catch((e) => console.warn('setZoom failed', String(e).slice(0, 120)));
|
||||
}
|
||||
},
|
||||
[holdCover, stopCoverTimer]
|
||||
);
|
||||
// 0.5x is a chip wherever an ultra-wide can actually be shown: the hidden
|
||||
// Camera2 lens above, or a bound CameraX device whose minZoomRatio reaches
|
||||
// below 1 (a logical multi-camera with the ultra-wide wired in). A plain
|
||||
// single-lens device reports 1 (and 0.0 before the session binds, which is
|
||||
// why the gate is `> 0`), and setZoom rejects anything below min.
|
||||
const zoomPresets = useMemo(() => {
|
||||
const base = [1, 2, 3, 5, 10];
|
||||
const min = device?.minZoom ?? 1;
|
||||
if (ultraWideReady) return [0.5, ...base];
|
||||
return min > 0 && min <= 0.5 ? [0.5, ...base] : base;
|
||||
}, [device, ultraWideReady]);
|
||||
// Re-apply after the camera session restarts (background resume, camera↔
|
||||
// library switch): a fresh session starts back at 1x.
|
||||
useEffect(() => {
|
||||
@@ -520,7 +673,10 @@ const Viewfinder = forwardRef<ViewfinderHandle, ViewfinderProps>(function Viewfi
|
||||
useEffect(() => {
|
||||
zoomRef.current = 1;
|
||||
setZoomRatio(1);
|
||||
}, [facing]);
|
||||
setUltraWideOn(false);
|
||||
stopCoverTimer();
|
||||
setUwCover(false);
|
||||
}, [facing, stopCoverTimer]);
|
||||
// The quick-EV bubble/drag is the recipe's OWN EV: `adjustments` above also
|
||||
// carries the metering-mode dip (highlight-weighted), so reading it here
|
||||
// showed EV -0.5 while the LIGHT tab slider sat at 0 — and a drag then wrote
|
||||
@@ -534,6 +690,9 @@ const Viewfinder = forwardRef<ViewfinderHandle, ViewfinderProps>(function Viewfi
|
||||
getFrameWindowZoom() {
|
||||
return frameZoomRef.current;
|
||||
},
|
||||
isUltraWide() {
|
||||
return ultraWideOnRef.current;
|
||||
},
|
||||
getCropRatioRect() {
|
||||
return cropRatioRectRef.current;
|
||||
},
|
||||
@@ -553,6 +712,17 @@ const Viewfinder = forwardRef<ViewfinderHandle, ViewfinderProps>(function Viewfi
|
||||
// preview (lift highlights -> AE underexposes -> looks unchanged), which is
|
||||
// why HL/SH edits read as "no effect" on the live preview.
|
||||
const handleTapFocus = async (x: number, y: number) => {
|
||||
// 0.5x is a native lens whose CameraX session is stopped, so focusTo()
|
||||
// has no camera behind it and the tap has to go to the module.
|
||||
if (ultraWideOnRef.current) {
|
||||
setFocusPoint({ x, y });
|
||||
try {
|
||||
await UltraWide.focusAsync(x, y);
|
||||
} catch (e) {
|
||||
console.warn('Ultra-wide focus failed: ' + String(e).slice(0, 120));
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (!cameraActive) return;
|
||||
const cam = skiaCameraRef.current;
|
||||
const d = cam?.controller?.device;
|
||||
@@ -572,6 +742,15 @@ const Viewfinder = forwardRef<ViewfinderHandle, ViewfinderProps>(function Viewfi
|
||||
}
|
||||
};
|
||||
const handleUnlockFocus = async () => {
|
||||
if (ultraWideOnRef.current) {
|
||||
setFocusPoint(null);
|
||||
try {
|
||||
await UltraWide.resetFocusAsync();
|
||||
} catch (e) {
|
||||
console.warn('Ultra-wide focus reset failed: ' + String(e).slice(0, 120));
|
||||
}
|
||||
return;
|
||||
}
|
||||
const cam = skiaCameraRef.current;
|
||||
if (!cam) return;
|
||||
try {
|
||||
@@ -865,6 +1044,11 @@ const Viewfinder = forwardRef<ViewfinderHandle, ViewfinderProps>(function Viewfi
|
||||
g.lastSent = rounded;
|
||||
g.param.onChange(rounded);
|
||||
};
|
||||
// Latest prop through a ref: the responder props read it on every event, but
|
||||
// a ref keeps a stale closure from ever firing a capture after the toggle
|
||||
// was switched off mid-gesture.
|
||||
const onTouchShutterRef = useRef(onTouchShutter);
|
||||
onTouchShutterRef.current = onTouchShutter;
|
||||
const onImageDragEnd = (e: any) => {
|
||||
const g = imgDragRef.current;
|
||||
imgDragRef.current = null;
|
||||
@@ -890,7 +1074,12 @@ const Viewfinder = forwardRef<ViewfinderHandle, ViewfinderProps>(function Viewfi
|
||||
if (!g.moved && !peeked) {
|
||||
// Plain tap: lock focus at the tapped point (drag never re-locks). A
|
||||
// hold-to-compare is not a tap either.
|
||||
handleTapFocus(e.nativeEvent.locationX, e.nativeEvent.locationY);
|
||||
// Touch-to-shutter chains the capture AFTER the focus promise: focusTo()
|
||||
// only resolves once focus has fully settled (rejects on timeout/cancel),
|
||||
// so waiting on it is exactly "focus first, then shoot" — no timer.
|
||||
void handleTapFocus(e.nativeEvent.locationX, e.nativeEvent.locationY).then(() =>
|
||||
onTouchShutterRef.current?.()
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -944,13 +1133,48 @@ const Viewfinder = forwardRef<ViewfinderHandle, ViewfinderProps>(function Viewfi
|
||||
} catch (e) {
|
||||
console.warn('Color matrix build failed: ' + e);
|
||||
}
|
||||
// CLARITY: the export pass 4 twin — unsharp convolution above 0, mist
|
||||
// blur below. Applied to the base paint so it sits under the glow and
|
||||
// the panel actually shows what the file will get.
|
||||
try {
|
||||
const clarityV = claritySync.getDirty();
|
||||
if (typeof clarityV === 'number' && Number.isFinite(clarityV) && clarityV !== 0) {
|
||||
const clarityFilter =
|
||||
clarityV > 0
|
||||
? (() => {
|
||||
const a = (clarityV / 10) * 0.8;
|
||||
return Skia.ImageFilter.MakeMatrixConvolution(
|
||||
3,
|
||||
3,
|
||||
[0, -a, 0, -a, 1 + 4 * a, -a, 0, -a, 0],
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
T_CLAMP,
|
||||
true,
|
||||
null
|
||||
);
|
||||
})()
|
||||
: Skia.ImageFilter.MakeBlur(
|
||||
Math.abs(clarityV / 10) * 4,
|
||||
Math.abs(clarityV / 10) * 4,
|
||||
T_CLAMP,
|
||||
null
|
||||
);
|
||||
if (clarityFilter != null) paint.setImageFilter(clarityFilter);
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('Clarity filter build failed: ' + e);
|
||||
}
|
||||
const tone = toneSync.getDirty();
|
||||
const cinema = cinemaSync.getDirty();
|
||||
const hasTone =
|
||||
toneEffect != null &&
|
||||
(tone[0] !== 0 || tone[1] !== 0 || tone[2] !== 0 || tone[3] !== 0 ||
|
||||
tone[4] !== 0 || tone[5] !== 0 || tone[6] !== 0 ||
|
||||
tone[7] !== 0 || tone[8] !== 0 || tone[9] !== 0);
|
||||
tone[7] !== 0 || tone[8] !== 0 || tone[9] !== 0 ||
|
||||
tone[10] !== 0 || tone[11] !== 0);
|
||||
const hasCinema = cinemaEffect != null && cinema[0] > 0;
|
||||
// Draws the full frame with the tone/cinema shader when active (drawRect,
|
||||
// not drawImage: paint shaders only apply to geometry fills — drawImage
|
||||
@@ -985,45 +1209,67 @@ const Viewfinder = forwardRef<ViewfinderHandle, ViewfinderProps>(function Viewfi
|
||||
} else {
|
||||
canvas.drawImage(frameTexture, 0, 0, paint);
|
||||
}
|
||||
// HDF: mirror of export pass 5b — extract highlights with a hard
|
||||
// contrast curve, blur that selection, Screen it back on top. Screen
|
||||
// against near-black is a no-op, so shadows stay put. Same source
|
||||
// (shader or image) the base draw used, so the glow sits on processed
|
||||
// pixels. Radius is a fraction of width so preview/export bloom alike.
|
||||
// HDF: mirror of export pass 5b — Bright Pass Filter on the LUMINANCE,
|
||||
// blurred, Screened back on top. Below the knee the pass returns exact
|
||||
// 0.0 and Screen against black is a no-op, so the shadows stay put
|
||||
// while only the lit areas spread their light. Same source (shader or
|
||||
// image) the base draw used, so the glow sits on processed pixels.
|
||||
// Radius is a fraction of width so preview/export bloom alike.
|
||||
const hdfV = hdfSync.getDirty();
|
||||
if (hdfV > 0) {
|
||||
const strength = hdfV / 10;
|
||||
const width = frameTexture.width();
|
||||
const sigma = width * (0.004 + 0.015 * strength);
|
||||
const hiMatrix = [
|
||||
2.5, 0, 0, 0, -1.5,
|
||||
0, 2.5, 0, 0, -1.5,
|
||||
0, 0, 2.5, 0, -1.5,
|
||||
0, 0, 0, 1, 0,
|
||||
];
|
||||
const hiFilter = Skia.ColorFilter.MakeMatrix(hiMatrix);
|
||||
// Order is load-bearing (see exportEngine pass 5b): chain both as
|
||||
// image filters so the highlight selection runs BEFORE the blur.
|
||||
const selection = Skia.ImageFilter.MakeColorFilter(
|
||||
baseColorFilter != null
|
||||
? Skia.ColorFilter.MakeCompose(hiFilter, baseColorFilter)
|
||||
: hiFilter,
|
||||
null
|
||||
);
|
||||
const srcShader =
|
||||
fullShader != null
|
||||
? fullShader
|
||||
: frameTexture.makeShaderOptions(T_CLAMP, T_CLAMP, F_LINEAR, M_NONE);
|
||||
const glowShader =
|
||||
glowEffect != null ? glowEffect.makeShaderWithChildren(glowUniformArray(), [srcShader]) : null;
|
||||
const glowPaint = Skia.Paint();
|
||||
glowPaint.setBlendMode(BlendMode.Screen);
|
||||
glowPaint.setAlphaf(0.15 + 0.35 * strength);
|
||||
glowPaint.setImageFilter(
|
||||
Skia.ImageFilter.MakeCompose(
|
||||
Skia.ImageFilter.MakeBlur(sigma, sigma, TileMode.Clamp, null),
|
||||
selection
|
||||
)
|
||||
);
|
||||
if (fullShader != null) {
|
||||
glowPaint.setShader(fullShader);
|
||||
if (glowShader != null) {
|
||||
glowPaint.setShader(glowShader);
|
||||
// Recipe matrix stays where it always was — inside the filter
|
||||
// chain, under the blur — so the bloom lands on GRADED pixels
|
||||
// (a monochrome recipe glows white, not colour).
|
||||
const glowBlur = Skia.ImageFilter.MakeBlur(sigma, sigma, TileMode.Clamp, null);
|
||||
glowPaint.setImageFilter(
|
||||
baseColorFilter != null
|
||||
? Skia.ImageFilter.MakeCompose(glowBlur, Skia.ImageFilter.MakeColorFilter(baseColorFilter, null))
|
||||
: glowBlur
|
||||
);
|
||||
canvas.drawRect(Skia.XYWHRect(0, 0, width, frameTexture.height()), glowPaint);
|
||||
} else {
|
||||
canvas.drawImage(frameTexture, 0, 0, glowPaint);
|
||||
// GLOW_SKSL unavailable: old per-channel curve (composed on TOP of
|
||||
// the recipe matrix, the order that keeps a monochrome look white).
|
||||
const hiMatrix = [
|
||||
2.5, 0, 0, 0, -1.5,
|
||||
0, 2.5, 0, 0, -1.5,
|
||||
0, 0, 2.5, 0, -1.5,
|
||||
0, 0, 0, 1, 0,
|
||||
];
|
||||
// Order is load-bearing (see exportEngine pass 5b): chain both as
|
||||
// image filters so the highlight selection runs BEFORE the blur.
|
||||
const selection = Skia.ImageFilter.MakeColorFilter(
|
||||
baseColorFilter != null
|
||||
? Skia.ColorFilter.MakeCompose(Skia.ColorFilter.MakeMatrix(hiMatrix), baseColorFilter)
|
||||
: Skia.ColorFilter.MakeMatrix(hiMatrix),
|
||||
null
|
||||
);
|
||||
glowPaint.setImageFilter(
|
||||
Skia.ImageFilter.MakeCompose(
|
||||
Skia.ImageFilter.MakeBlur(sigma, sigma, TileMode.Clamp, null),
|
||||
selection
|
||||
)
|
||||
);
|
||||
if (fullShader != null) {
|
||||
glowPaint.setShader(fullShader);
|
||||
canvas.drawRect(Skia.XYWHRect(0, 0, width, frameTexture.height()), glowPaint);
|
||||
} else {
|
||||
canvas.drawImage(frameTexture, 0, 0, glowPaint);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -1144,7 +1390,7 @@ const Viewfinder = forwardRef<ViewfinderHandle, ViewfinderProps>(function Viewfi
|
||||
});
|
||||
frame.dispose();
|
||||
},
|
||||
[colorMatrixSync, toneSync, cinemaSync, hdfSync, toneFailLogged, feedRectSync, frameBeatSync]
|
||||
[colorMatrixSync, toneSync, cinemaSync, hdfSync, claritySync, toneFailLogged, feedRectSync, frameBeatSync]
|
||||
);
|
||||
|
||||
const grainOpacity = adjustments.grain / 20;
|
||||
@@ -1456,7 +1702,7 @@ const Viewfinder = forwardRef<ViewfinderHandle, ViewfinderProps>(function Viewfi
|
||||
stallTicks = 0;
|
||||
}
|
||||
// Backgrounding pauses the session on purpose — never watchdog that.
|
||||
if (!startedOnceRef.current || !appActiveRef.current) return;
|
||||
if (!startedOnceRef.current || !appActiveRef.current || ultraWideOnRef.current) return;
|
||||
// Two stale ticks give an in-flight (flip/resume/remount) restart time
|
||||
// to land.
|
||||
if (stallTicks < 2) return;
|
||||
@@ -2409,7 +2655,7 @@ const Viewfinder = forwardRef<ViewfinderHandle, ViewfinderProps>(function Viewfi
|
||||
ref={skiaCameraRef}
|
||||
style={StyleSheet.absoluteFill}
|
||||
device={device}
|
||||
isActive={appActive}
|
||||
isActive={appActive && !ultraWideOn}
|
||||
outputs={cameraOutputs}
|
||||
pixelFormat="yuv"
|
||||
targetResolution={frameTarget}
|
||||
@@ -2418,6 +2664,15 @@ const Viewfinder = forwardRef<ViewfinderHandle, ViewfinderProps>(function Viewfi
|
||||
onFrame={handleFrame}
|
||||
onError={onSessionError}
|
||||
/>
|
||||
{/* 0.5x: a native Camera2 session on the hidden ultra-wide lens, drawn
|
||||
over the (stopped) SkiaCamera feed. The main session has to stay
|
||||
mounted but inactive — the two lenses are mutually exclusive, so
|
||||
turning one on means turning the other off, never both at once. */}
|
||||
<UltraWidePreview
|
||||
style={StyleSheet.absoluteFill}
|
||||
active={appActive && ultraWideOn}
|
||||
cover={uwCover}
|
||||
/>
|
||||
{/* 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. */}
|
||||
@@ -2484,18 +2739,20 @@ const Viewfinder = forwardRef<ViewfinderHandle, ViewfinderProps>(function Viewfi
|
||||
)}
|
||||
{renderCameraOverlays()}
|
||||
{/* Native zoom presets — snap to a controller zoom ratio. 1x is the
|
||||
only optical stop on the main back lens; 2x-10x are digital crop
|
||||
(CameraX exposes no tele/ultra-wide logical device here). Row sits
|
||||
main lens; 0.5x appears only on a device whose CameraX zoom range
|
||||
reaches below 1 (ultra-wide), and 2x-10x are digital crop. Row sits
|
||||
under the TopBar, clear of the AE-lock chip (top-right) and the EV
|
||||
strip (right edge). */}
|
||||
{controller && (
|
||||
strip (right edge). uwCover keeps the row mounted across the
|
||||
0.5x handoff: the 1x session has not restarted yet, so
|
||||
`controller` is still undefined and the row used to blink. */}
|
||||
{(controller || ultraWideOn || uwCover) && (
|
||||
<View
|
||||
pointerEvents="box-none"
|
||||
className="absolute left-0 right-0 items-center"
|
||||
style={{ top: insets.top + 62 }}
|
||||
>
|
||||
<View className="flex-row rounded-full border border-zinc-700/70 bg-black/50 p-1">
|
||||
{[1, 2, 3, 5, 10].map((z) => (
|
||||
{zoomPresets.map((z) => (
|
||||
<TouchableOpacity
|
||||
key={z}
|
||||
onPress={() => applyZoom(z)}
|
||||
@@ -2534,10 +2791,98 @@ const Viewfinder = forwardRef<ViewfinderHandle, ViewfinderProps>(function Viewfi
|
||||
hlTr: toneParams[7],
|
||||
hlTg: toneParams[8],
|
||||
hlTb: toneParams[9],
|
||||
cc: toneParams[10],
|
||||
ccb: toneParams[11],
|
||||
};
|
||||
const cinemaOn = cinemaEffect != null && !!cinemaParams;
|
||||
const useShaderPass = !!imageFitRect && ((toneEffect != null && toneOn) || cinemaOn);
|
||||
|
||||
// ---- CLARITY + LIGHT GLOW on the LIBRARY preview -----------------------
|
||||
// The camera worklet and exportEngine both have these two passes; the three
|
||||
// library branches below draw the photo declaratively and had neither, so a
|
||||
// photo opened from the library ignored both knobs. Same maths as the camera:
|
||||
// `sx`/`sy` turn one ORIGINAL image pixel into canvas units (the library
|
||||
// preview is laid out in dp, the camera surface is in sensor px), keeping the
|
||||
// unsharp radius and the bloom sigma identical to the file.
|
||||
const libClarity = adjustments.clarity ?? 0;
|
||||
const libGlow = (adjustments.hdf ?? 0) / 10;
|
||||
const libShaderPath = useShaderPass || (libClarity > 0 && clarityEffect != null);
|
||||
const libPhoto = (
|
||||
r: { x: number; y: number; w: number; h: number },
|
||||
fit: 'cover' | 'fill' | 'contain'
|
||||
) => {
|
||||
const iw = skiaImage ? skiaImage.width() : r.w;
|
||||
const ih = skiaImage ? skiaImage.height() : r.h;
|
||||
// cover/contain scale uniformly (max/min), fill stretches each axis.
|
||||
const sw = r.w / iw;
|
||||
const sh = r.h / ih;
|
||||
const uniform = fit === 'cover' ? Math.max(sw, sh) : fit === 'contain' ? Math.min(sw, sh) : null;
|
||||
const sx = uniform ?? sw;
|
||||
const sy = uniform ?? sh;
|
||||
const rectNode = rect(r.x, r.y, r.w, r.h);
|
||||
let graded: React.ReactNode = <ImageShader image={skiaImage} fit={fit} tx="clamp" ty="clamp" rect={rectNode} />;
|
||||
if (toneEffect != null && toneOn) {
|
||||
graded = <Shader source={toneEffect} uniforms={toneUniforms}>{graded}</Shader>;
|
||||
}
|
||||
if (cinemaOn) {
|
||||
graded = <Shader source={cinemaEffect!} uniforms={cinemaParams!.uniforms}>{graded}</Shader>;
|
||||
}
|
||||
// The Rect must cover exactly the photo area, never the full canvas: a
|
||||
// fullscreen ImageShader with fit="contain" clamps the photo edges across
|
||||
// the black letterbox (reads as "the background is a zoomed copy").
|
||||
let photo: React.ReactNode = libShaderPath ? (
|
||||
<Rect x={r.x} y={r.y} width={r.w} height={r.h}>
|
||||
{libClarity > 0 && clarityEffect != null ? (
|
||||
<Shader source={clarityEffect} uniforms={clarityUniforms((libClarity / 10) * 0.8, sx, sy)}>
|
||||
{graded}
|
||||
</Shader>
|
||||
) : (
|
||||
graded
|
||||
)}
|
||||
</Rect>
|
||||
) : (
|
||||
<SkiaImage image={skiaImage} fit={fit} rect={rectNode} />
|
||||
);
|
||||
// Mist (negative clarity) = export pass 4's blur; sigma in original px.
|
||||
if (libClarity < 0) {
|
||||
photo = (
|
||||
<Group>
|
||||
<Blur blur={Math.abs(libClarity / 10) * 4 * sx} mode="clamp" />
|
||||
{photo}
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
// Export clips the bloom and the mist to the canvas, which IS the photo.
|
||||
// The preview must clip too: the HDF glow's blur (and negative clarity's)
|
||||
// otherwise bleeds past the photo onto the black letterbox and over the
|
||||
// frame's mat. Clip to the drawn image, not to the rect: on "contain" the
|
||||
// rect is wider than the picture.
|
||||
const cw = Math.min(r.w, iw * sx);
|
||||
const ch = Math.min(r.h, ih * sy);
|
||||
const photoClip = rect(r.x + (r.w - cw) / 2, r.y + (r.h - ch) / 2, cw, ch);
|
||||
return (
|
||||
<Group clip={photoClip}>
|
||||
{photo}
|
||||
{/* HDF bloom, the export pass 5b twin: bright pass on the luminance,
|
||||
blur, Screen back at 0.15..0.5 x strength. Radius is a fraction of
|
||||
the drawn width, so any image size blooms like the camera does. */}
|
||||
{libGlow > 0 && glowEffect != null ? (
|
||||
<Rect
|
||||
x={r.x}
|
||||
y={r.y}
|
||||
width={r.w}
|
||||
height={r.h}
|
||||
blendMode="screen"
|
||||
opacity={0.15 + 0.35 * libGlow}
|
||||
>
|
||||
<Blur blur={cw * (0.004 + 0.015 * libGlow)} mode="clamp" />
|
||||
<Shader source={glowEffect} uniforms={GLOW_UNIFORMS}>{graded}</Shader>
|
||||
</Rect>
|
||||
) : null}
|
||||
</Group>
|
||||
);
|
||||
};
|
||||
|
||||
// RETRO INSTANT (library): the whole picture is contain-fitted into the
|
||||
// card's photo window — nothing is masked or cropped, and the card is
|
||||
// centered on the black background like a physical print. The window keeps
|
||||
@@ -2556,33 +2901,7 @@ const Viewfinder = forwardRef<ViewfinderHandle, ViewfinderProps>(function Viewfi
|
||||
<Group clip={winRect}>
|
||||
<Group matrix={libZoomMatrix}>
|
||||
<ColorMatrix matrix={colorMatrix} />
|
||||
{useShaderPass ? (
|
||||
<Rect x={win.x} y={win.y} width={win.w} height={win.h}>
|
||||
{cinemaOn ? (
|
||||
<Shader source={cinemaEffect!} uniforms={cinemaParams!.uniforms}>
|
||||
{toneEffect && toneOn ? (
|
||||
<Shader
|
||||
source={toneEffect}
|
||||
uniforms={toneUniforms}
|
||||
>
|
||||
<ImageShader image={skiaImage} fit={polaroidFit} tx="clamp" ty="clamp" rect={winRect} />
|
||||
</Shader>
|
||||
) : (
|
||||
<ImageShader image={skiaImage} fit={polaroidFit} tx="clamp" ty="clamp" rect={winRect} />
|
||||
)}
|
||||
</Shader>
|
||||
) : toneEffect && toneOn ? (
|
||||
<Shader
|
||||
source={toneEffect}
|
||||
uniforms={toneUniforms}
|
||||
>
|
||||
<ImageShader image={skiaImage} fit={polaroidFit} tx="clamp" ty="clamp" rect={winRect} />
|
||||
</Shader>
|
||||
) : null}
|
||||
</Rect>
|
||||
) : (
|
||||
<SkiaImage image={skiaImage} fit={polaroidFit} rect={winRect} />
|
||||
)}
|
||||
{libPhoto({ x: win.x, y: win.y, w: win.w, h: win.h }, polaroidFit)}
|
||||
</Group>
|
||||
</Group>
|
||||
{grainOpacity > 0 && noiseEffect && (
|
||||
@@ -2640,33 +2959,7 @@ const Viewfinder = forwardRef<ViewfinderHandle, ViewfinderProps>(function Viewfi
|
||||
<Group clip={winRect}>
|
||||
<Group matrix={libZoomMatrix}>
|
||||
<ColorMatrix matrix={colorMatrix} />
|
||||
{useShaderPass ? (
|
||||
<Rect x={win.x} y={win.y} width={win.w} height={win.h}>
|
||||
{cinemaOn ? (
|
||||
<Shader source={cinemaEffect!} uniforms={cinemaParams!.uniforms}>
|
||||
{toneEffect && toneOn ? (
|
||||
<Shader
|
||||
source={toneEffect}
|
||||
uniforms={toneUniforms}
|
||||
>
|
||||
<ImageShader image={skiaImage} fit="cover" tx="clamp" ty="clamp" rect={winRect} />
|
||||
</Shader>
|
||||
) : (
|
||||
<ImageShader image={skiaImage} fit="cover" tx="clamp" ty="clamp" rect={winRect} />
|
||||
)}
|
||||
</Shader>
|
||||
) : toneEffect && toneOn ? (
|
||||
<Shader
|
||||
source={toneEffect}
|
||||
uniforms={toneUniforms}
|
||||
>
|
||||
<ImageShader image={skiaImage} fit="cover" tx="clamp" ty="clamp" rect={winRect} />
|
||||
</Shader>
|
||||
) : null}
|
||||
</Rect>
|
||||
) : (
|
||||
<SkiaImage image={skiaImage} fit="cover" rect={winRect} />
|
||||
)}
|
||||
{libPhoto({ x: win.x, y: win.y, w: win.w, h: win.h }, 'cover')}
|
||||
</Group>
|
||||
</Group>
|
||||
{grainOpacity > 0 && noiseEffect && (
|
||||
@@ -2709,66 +3002,10 @@ const Viewfinder = forwardRef<ViewfinderHandle, ViewfinderProps>(function Viewfi
|
||||
<Group matrix={viewZoomMatrix}>
|
||||
<Group matrix={libViewMatrix}>
|
||||
<ColorMatrix matrix={colorMatrix} />
|
||||
{useShaderPass ? (
|
||||
// DR/Highlight/Shadow tone + Cinema seasonal grade, both live
|
||||
// over the photo. The Rect must cover exactly the contain-fitted
|
||||
// photo area (not the full canvas): a fullscreen ImageShader
|
||||
// with fit="contain" clamps/smears the photo edges across the
|
||||
// black letterbox (user-visible as "the background is a zoomed
|
||||
// copy of the photo"). Cinema samples the tone output when both
|
||||
// are on (chain: cinema -> tone -> image), matrix applies after.
|
||||
<Rect
|
||||
x={dispRect!.dx}
|
||||
y={dispRect!.dy}
|
||||
width={dispRect!.dw}
|
||||
height={dispRect!.dh}
|
||||
>
|
||||
{cinemaOn ? (
|
||||
<Shader source={cinemaEffect!} uniforms={cinemaParams!.uniforms}>
|
||||
{toneEffect && toneOn ? (
|
||||
<Shader
|
||||
source={toneEffect}
|
||||
uniforms={toneUniforms}
|
||||
>
|
||||
<ImageShader
|
||||
image={skiaImage}
|
||||
fit={dispFit}
|
||||
tx="clamp"
|
||||
ty="clamp"
|
||||
rect={rect(dispRect!.dx, dispRect!.dy, dispRect!.dw, dispRect!.dh)}
|
||||
/>
|
||||
</Shader>
|
||||
) : (
|
||||
<ImageShader
|
||||
image={skiaImage}
|
||||
fit={dispFit}
|
||||
tx="clamp"
|
||||
ty="clamp"
|
||||
rect={rect(dispRect!.dx, dispRect!.dy, dispRect!.dw, dispRect!.dh)}
|
||||
/>
|
||||
)}
|
||||
</Shader>
|
||||
) : toneEffect && toneOn ? (
|
||||
<Shader
|
||||
source={toneEffect}
|
||||
uniforms={toneUniforms}
|
||||
>
|
||||
<ImageShader
|
||||
image={skiaImage}
|
||||
fit={dispFit}
|
||||
tx="clamp"
|
||||
ty="clamp"
|
||||
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)}
|
||||
/>
|
||||
{dispRect ? (
|
||||
// Tone/Cinema chain, CLARITY and HDF bloom all live in
|
||||
// libPhoto — see its comment for the rect caveat.
|
||||
libPhoto({ x: dispRect.dx, y: dispRect.dy, w: dispRect.dw, h: dispRect.dh }, dispFit)
|
||||
) : (
|
||||
<SkiaImage image={skiaImage} fit="contain" rect={rect(0, 0, vw, vh)} />
|
||||
)}
|
||||
|
||||
@@ -6,8 +6,8 @@ import { Animated, PanResponder } from 'react-native';
|
||||
// While zoomed in, responder termination is refused so the parent ScrollView
|
||||
// cannot steal the gesture — the photo pans instead of paging; swiping to the
|
||||
// next photo resumes after zooming back out (double tap or pinch below 1.15).
|
||||
// Two-finger pinch only zooms once the photo is already zoomed in: at 100% the
|
||||
// pinch stays inert, so the paging strip still behaves like a plain gallery.
|
||||
// Pinch zooms from any scale: a two-finger gesture never pages the strip, so
|
||||
// pinching out of 100% cannot be mistaken for a swipe.
|
||||
//
|
||||
// ponytail: pinch zooms around the image center, not the finger midpoint;
|
||||
// focal zoom needs per-touch location math against the image's screen rect.
|
||||
@@ -116,13 +116,10 @@ export default function ZoomableImage({ uri, width }: ZoomableImageProps) {
|
||||
const b = touches[1];
|
||||
const dist = Math.hypot(a.pageX - b.pageX, a.pageY - b.pageY);
|
||||
if (!st.pinchStart) st.pinchStart = { scale: st.scale, dist: dist || 1 };
|
||||
const next =
|
||||
st.pinchStart.scale > 1.001
|
||||
? Math.max(
|
||||
1,
|
||||
Math.min(MAX_SCALE, st.pinchStart.scale * (dist / st.pinchStart.dist))
|
||||
)
|
||||
: 1;
|
||||
const next = Math.max(
|
||||
1,
|
||||
Math.min(MAX_SCALE, st.pinchStart.scale * (dist / st.pinchStart.dist))
|
||||
);
|
||||
st.scale = next;
|
||||
if (next <= 1.001) {
|
||||
st.tx = 0;
|
||||
|
||||
@@ -1,129 +0,0 @@
|
||||
import RecipescamExport, { PhotoAdjust } from '../../modules/recipescam-export';
|
||||
import { applyExposureGain, getSkiaColorMatrix } from '../utils/colorUtils';
|
||||
import { getCinemaUniforms } from '../utils/cinemaShader';
|
||||
import { BaseFilter, ColorAdjustments } from '../types';
|
||||
|
||||
const RUNS = 2;
|
||||
const IDENTITY = (() => {
|
||||
const m = new Array(20).fill(0);
|
||||
m[0] = m[6] = m[12] = m[18] = 1;
|
||||
return m;
|
||||
})();
|
||||
|
||||
const NEUTRAL_ADJ: ColorAdjustments = {
|
||||
exposure: 0,
|
||||
contrast: 0,
|
||||
saturation: 0,
|
||||
temperature: 5500,
|
||||
tint: 0,
|
||||
highlight: 0,
|
||||
shadow: 0,
|
||||
denoise: 0,
|
||||
clarity: 0,
|
||||
grain: 0,
|
||||
colorChrome: 'none',
|
||||
colorChromeBlue: 'none',
|
||||
dynamicRange: 100,
|
||||
sharpening: 0,
|
||||
exposureCompensation: 0,
|
||||
};
|
||||
|
||||
/**
|
||||
* P0-P3 probe harness (temporary — removed at P7). Reads the native
|
||||
* RecipesCamExport "centerPixel"/"finalPixel" log lines (probe gates compare
|
||||
* those against the node reference sim).
|
||||
*/
|
||||
export async function nativeBenchProbe(): Promise<void> {
|
||||
const m = RecipescamExport as any;
|
||||
try {
|
||||
const pong = await m.ping();
|
||||
const run = async (tag: string, src: string, o: PhotoAdjust & { matrix?: number[] } = {}) => {
|
||||
const { matrix, ...adj } = o;
|
||||
const dst = src.replace(/\.png$/, `-${tag}-${Date.now()}.jpg`);
|
||||
const t = await m.processPhotoAsync(src, dst, matrix ?? IDENTITY, null, adj);
|
||||
console.error(`[BENCH] ${tag} totalMs=${t.totalMs} decodeMs=${t.decodeMs} toneMs=${t.toneMs ?? 0} cinemaMs=${t.cinemaMs ?? 0} colorMs=${t.colorMs} clarityMs=${t.clarityMs ?? 0} grainMs=${t.grainMs ?? 0} frameMs=${t.frameMs ?? 0} encodeWriteMs=${t.encodeWriteMs}`);
|
||||
};
|
||||
const src: string = await m.materializeBenchAsset('wallframe');
|
||||
|
||||
// P1 parity: classic-neg matrix on black -> expect center ~(13,5,0).
|
||||
const matrix = applyExposureGain(getSkiaColorMatrix('classic-neg' as BaseFilter, NEUTRAL_ADJ), 0);
|
||||
const black = await m.materializeSolidAsset(0, 0, 0);
|
||||
const blackOut = black.replace(/\.png$/, `-neg-${Date.now()}.jpg`);
|
||||
await m.processColorAsync(black, blackOut, matrix, null, 95);
|
||||
console.error('[BENCH] classic-neg-on-black matrix[0..4]=' + matrix.slice(0, 5).join(','));
|
||||
|
||||
// P2 tone parity on solid ramps (expected values computed from TONE_SKSL):
|
||||
// white(255) hl=-1 -> 199; gray64 sh=+1 -> 113; white dr=1 -> 209;
|
||||
// gray26 sh=+1 -> 105; gray26 hl=+1 sh=-1 -> 0.
|
||||
const toneGates: Array<{ rgb: [number, number, number]; tone: { dr: number; hl: number; sh: number }; tag: string }> = [
|
||||
{ rgb: [255, 255, 255], tone: { dr: 0, hl: -1, sh: 0 }, tag: 'white-hl-1' },
|
||||
{ rgb: [64, 64, 64], tone: { dr: 0, hl: 0, sh: 1 }, tag: 'gray64-sh+1' },
|
||||
{ rgb: [255, 255, 255], tone: { dr: 1, hl: 0, sh: 0 }, tag: 'white-dr1' },
|
||||
{ rgb: [26, 26, 26], tone: { dr: 0, hl: 0, sh: 1 }, tag: 'gray26-sh+1' },
|
||||
{ rgb: [26, 26, 26], tone: { dr: 0, hl: 1, sh: -1 }, tag: 'gray26-hl+1-sh-1' },
|
||||
];
|
||||
for (const g of toneGates) {
|
||||
const solid = await m.materializeSolidAsset(g.rgb[0], g.rgb[1], g.rgb[2]);
|
||||
await run(`tone-${g.tag}`, solid, { toneDr: g.tone.dr, toneHl: g.tone.hl, toneSh: g.tone.sh });
|
||||
}
|
||||
|
||||
// P3 cinema parity (CINEMA_SKSL): lit+warm+glow path, shade+cool+fog path,
|
||||
// and a saturated pixel to exercise the saturation mix.
|
||||
const summer = getCinemaUniforms('summer')!.flat;
|
||||
const winter = getCinemaUniforms('winter')!.flat;
|
||||
const s192 = await m.materializeSolidAsset(192, 192, 192);
|
||||
await run('cinema-summer-192', s192, { cinema: summer });
|
||||
const s32 = await m.materializeSolidAsset(32, 32, 32);
|
||||
await run('cinema-winter-32', s32, { cinema: winter });
|
||||
const sat = await m.materializeSolidAsset(200, 120, 60);
|
||||
await run('cinema-summer-sat', sat, { cinema: summer });
|
||||
|
||||
// P3 enhance gates on the gray ramp (gradient makes blur/conv non-trivial).
|
||||
const ramp = await m.materializeRampAsset();
|
||||
await run('denoise10-ramp', ramp, { denoise: 10 });
|
||||
await run('clarity10-ramp', ramp, { clarity: 10 });
|
||||
await run('mist-ramp', ramp, { clarity: -10 });
|
||||
// Step gates: rows 0..31=0, 32..63=255. A ramp is invariant under box
|
||||
// blur, so the step makes heavy blur non-trivial (expected center row32:
|
||||
// denoise r=1 -> 170, mist r=4 -> 142 per the padded-boxBlur reference).
|
||||
const step = await m.materializeStepAsset();
|
||||
await run('denoise10-step', step, { denoise: 10 });
|
||||
await run('mist-step', step, { clarity: -10 });
|
||||
// Grain is structurally-gated only (float32 hash noise vs node doubles
|
||||
// differ) — the run must complete and the center must stay plausible.
|
||||
const gray100 = await m.materializeSolidAsset(100, 100, 100);
|
||||
await run('grain10-100', gray100, { grain: 10 });
|
||||
|
||||
// P4 frame parity on solid gray100 64x64 — border/bar/card colors and the
|
||||
// composed output geometry are fully predictable, so probeSamples assert
|
||||
// exact pixels. Expected (offline verifier re-checks):
|
||||
// classic-white: (1,1)=white border, (32,32)=100 photo, size 64x64.
|
||||
// cinematic: (1,1)=black bar, (32,32)=100, size 64x64.
|
||||
// polaroid: outH=round(64*(SIDE+DECK)+64*WIN_W)=78; (1,1)=card #FAF9F6,
|
||||
// (32,32)=100 photo, (32,70)=card deck.
|
||||
await run('frame-classic', gray100, { frameId: 'classic-white', png: true, probeSamples: [{ x: 1, y: 1 }, { x: 32, y: 32 }] });
|
||||
await run('frame-cinematic', gray100, { frameId: 'cinematic', png: true, probeSamples: [{ x: 1, y: 1 }, { x: 32, y: 32 }] });
|
||||
await run('frame-polaroid', gray100, { frameId: 'polaroid', png: true, probeSamples: [{ x: 1, y: 1 }, { x: 32, y: 32 }, { x: 32, y: 70 }] });
|
||||
// wallframe: export becomes the fixed 3117x4000 rotated artwork; center is
|
||||
// the cover-cropped photo (100), mat samples compare against wallframe.png
|
||||
// pixels offline (display->source: (dx,dy)->(dy, 3117-dx)). Art + fonts are
|
||||
// resolved natively from bundled resources — embedded metro assets expose
|
||||
// no readable file URI to JS (readAsStringAsync rejects 'wallframe').
|
||||
await run('frame-wallframe', gray100, { frameId: 'wallframe', png: true, probeSamples: [{ x: 100, y: 1500 }, { x: 2900, y: 200 }, { x: 1559, y: 1998 }] });
|
||||
|
||||
// P4 GPS watermark (no frame): 400x400 gray base, stamp bottom-left.
|
||||
// probeStampDiff logs stampPixelDiff/amber/white after drawing — counts > 0
|
||||
// prove the emoji + amber text actually rendered (vs a silent no-op).
|
||||
const gray400 = await m.materializeSolidSized(400, 400, 100, 100, 100);
|
||||
await run('geo-stamp', gray400, { geo: { locality: 'HANOI TEST', timestamp: Date.now() }, png: true, probeStampDiff: true });
|
||||
|
||||
// Full-artwork run: provia + tone + cinema + all enhance passes, 12MP.
|
||||
const provia = applyExposureGain(getSkiaColorMatrix('provia' as BaseFilter, NEUTRAL_ADJ), 0);
|
||||
for (let i = 0; i < RUNS; i++) {
|
||||
await run(`fullRun-${i}`, src, { matrix: provia, toneDr: 0.33, toneHl: 0.3, toneSh: 0.3, cinema: summer, denoise: 10, clarity: 10, grain: 10 });
|
||||
}
|
||||
console.error('[BENCH] done pong=' + pong);
|
||||
} catch (e) {
|
||||
console.error('[BENCH] failed', e);
|
||||
}
|
||||
}
|
||||
@@ -1,107 +0,0 @@
|
||||
// P6 parity probe (temporary — removed at P7). Runs ONE source image through
|
||||
// the legacy Skia engine (processAndExportPhoto) and the native Kotlin engine
|
||||
// (processAndExportPhotoNative) with identical recipe/options, measures wall
|
||||
// time per engine plus a JS-tick counter (legacy renders sync JSI → JS thread
|
||||
// stalls; native runs off-thread → ticks keep firing), then asks the module to
|
||||
// downscale-diff the two outputs and write a 3-panel composite
|
||||
// [legacy|native|diff*8] JPEG into cache + gallery for on-device QA.
|
||||
import RecipescamExport from '../../modules/recipescam-export';
|
||||
import * as FileSystem from 'expo-file-system/legacy';
|
||||
import * as MediaLibrary from 'expo-media-library/legacy';
|
||||
import { processAndExportPhoto } from '../utils/exportEngine';
|
||||
import { processAndExportPhotoNative } from '../utils/nativeExport';
|
||||
import { DEFAULT_RECIPES } from '../utils/defaultRecipes';
|
||||
|
||||
const stripScheme = (uri: string) => uri.replace(/^file:\/\//, '');
|
||||
const toUri = (p: string) => (/^(file|content|ph):\/\//.test(p) ? p : `file://${p}`);
|
||||
|
||||
interface ParityCase {
|
||||
tag: string;
|
||||
recipeId: string;
|
||||
}
|
||||
const CASES: ParityCase[] = [
|
||||
// matrix + tone + CC + enhance (denoise/clarity/grain) — full core pipeline.
|
||||
{ tag: 'classic-neg', recipeId: 'classic-neg-default' },
|
||||
// matrix + mist (clarity<0) + classic-white frame + grain.
|
||||
{ tag: 'retro-amber-frame', recipeId: 'retro-amber-default' },
|
||||
// cinema seasonal grade + tone + enhance.
|
||||
{ tag: 'cinema-summer', recipeId: 'cinema-summer-default' },
|
||||
];
|
||||
// Library-style export (evFromCamera falsy → 2^EV matrix gain applies, as it
|
||||
// does for gallery stills); aspect full so the whole frame is compared.
|
||||
const OPTIONS = { dpi: 300, sharpen: true };
|
||||
|
||||
async function newestGalleryPhoto(): Promise<string | null> {
|
||||
try {
|
||||
const perm = await MediaLibrary.requestPermissionsAsync();
|
||||
if (!perm.granted) return null;
|
||||
const page = await MediaLibrary.getAssetsAsync({
|
||||
first: 1,
|
||||
// @ts-ignore legacy SortByValue tuple shape
|
||||
sortBy: [['creationTime', false]],
|
||||
});
|
||||
const a = page.assets?.[0];
|
||||
return a && a.uri ? a.uri : null;
|
||||
} catch (e) {
|
||||
console.error('[PARITY] gallery read failed', e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function parityProbe(): Promise<void> {
|
||||
const m = RecipescamExport as any;
|
||||
try {
|
||||
const src = (await newestGalleryPhoto()) ?? toUri(await m.materializeBenchAsset('wallframe'));
|
||||
console.error(`[PARITY] source=${src}`);
|
||||
for (const c of CASES) {
|
||||
const recipe = DEFAULT_RECIPES.find((r) => r.id === c.recipeId);
|
||||
if (!recipe) {
|
||||
console.error(`[PARITY] ${c.tag}: recipe ${c.recipeId} missing`);
|
||||
continue;
|
||||
}
|
||||
const frameId = recipe.frameId ?? 'none';
|
||||
let ticks = 0;
|
||||
const iv = setInterval(() => {
|
||||
ticks += 1;
|
||||
}, 40);
|
||||
const t0 = Date.now();
|
||||
const legacy = await processAndExportPhoto(src, recipe, frameId, false, null, OPTIONS);
|
||||
const legacyMs = Date.now() - t0;
|
||||
const legacyTicks = ticks;
|
||||
ticks = 0;
|
||||
const t1 = Date.now();
|
||||
const native = await processAndExportPhotoNative(src, recipe, frameId, false, null, OPTIONS);
|
||||
const nativeMs = Date.now() - t1;
|
||||
const nativeTicks = ticks;
|
||||
clearInterval(iv);
|
||||
console.error(
|
||||
`[PARITY] ${c.tag}: legacyMs=${legacyMs} ticks=${legacyTicks} uri=${legacy?.uri} | nativeMs=${nativeMs} ticks=${nativeTicks} uri=${native?.uri}`
|
||||
);
|
||||
if (!legacy || !native) {
|
||||
console.error(`[PARITY] ${c.tag}: missing engine output`);
|
||||
continue;
|
||||
}
|
||||
const out = `${FileSystem.cacheDirectory}parity_${c.tag}_${Date.now()}.jpg`;
|
||||
const res = await m.parityCompareAsync(
|
||||
stripScheme(legacy.uri),
|
||||
stripScheme(native.uri),
|
||||
stripScheme(out),
|
||||
1280
|
||||
);
|
||||
let saved = 'not-saved';
|
||||
try {
|
||||
const perm = await MediaLibrary.requestPermissionsAsync();
|
||||
if (perm.granted) {
|
||||
const asset = await MediaLibrary.createAssetAsync(out);
|
||||
saved = `asset=${asset.id}`;
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('[PARITY] save composite failed', e);
|
||||
}
|
||||
console.error(`[PARITY] ${c.tag}: composite=${saved} stats=${JSON.stringify(res)}`);
|
||||
}
|
||||
console.error('[PARITY] done');
|
||||
} catch (e) {
|
||||
console.error('[PARITY] failed', e);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
// Generated by tools/set-variant.mjs - do not edit by hand.
|
||||
// false = Lite apk (free, unlockable with a key), true = Pro apk (sold as-is).
|
||||
export const IS_PRO_BUILD = false;
|
||||
+1
-1
@@ -59,7 +59,7 @@ export const MIN_CROP_FRAC = 0.08;
|
||||
// cool tint in shade, plus a seasonal atmospheric haze/fog veil.
|
||||
export type CinemaSeason = 'spring' | 'summer' | 'autumn' | 'winter';
|
||||
|
||||
export type BaseFilter = 'classic-neg' | 'classic-chrome' | 'provia' | 'velvia' | 'astia' | 'eterna' | 'monochrome' | 'leica' | 'none';
|
||||
export type BaseFilter = 'classic-neg' | 'classic-chrome' | 'provia' | 'velvia' | 'astia' | 'eterna' | 'monochrome' | 'leica' | 'leica-vivid' | 'none';
|
||||
|
||||
export interface ColorAdjustments {
|
||||
exposure: number; // -10 to +10 (mapped to matrix multiplier or offset)
|
||||
|
||||
+95
-63
@@ -1,28 +1,55 @@
|
||||
import { ColorAdjustments, BaseFilter } from '../types';
|
||||
|
||||
// Kelvin to RGB compensation for White Balance.
|
||||
// In photography, when WB is set HIGH (e.g. 10000K), the camera understands
|
||||
// the scene light is COLD and compensates with WARM tones (red/orange).
|
||||
// When WB is set LOW (e.g. 2500K), the camera compensates with COOL tones (blue).
|
||||
// The WB setting names the light the camera is told to expect, so its gain is
|
||||
// the INVERSE of the Planckian (blackbody) colour at K: set 10000K under a
|
||||
// daylight scene (5500K) and the camera thinks the light is bluer than it is
|
||||
// and warms the picture up; set 2500K and it goes cool/blue. Pinned to unity at
|
||||
// 5500K, the app's neutral reference.
|
||||
//
|
||||
// Two things a hand-drawn linear ramp gets wrong, both of them visible on skin:
|
||||
// - moving the three channels independently (the old one lifted green 20% at
|
||||
// 10000K) shifts LUMINANCE as well as colour, so a warm shot went yellow-
|
||||
// green rather than orange. Here every gain is divided by its own Rec.709
|
||||
// luma, so a neutral pixel keeps its level and only the cast moves.
|
||||
// - the excursion was arbitrary. These are the physical ratios of the sRGB
|
||||
// blackbody colours, tamed by a square root: the raw linear-light ratio
|
||||
// (2.1x on blue at 2500K) over-corrects once it lands on already tone-mapped
|
||||
// pixels, which is exactly the domain a 4x5 colour matrix works in.
|
||||
export function kelvinToRGB(kelvin: number): { r: number; g: number; b: number } {
|
||||
const k = Math.min(10000, Math.max(2500, kelvin));
|
||||
let r = 1.0, g = 1.0, b = 1.0;
|
||||
const light = planckianSRGB(k);
|
||||
const ref = planckianSRGB(5500);
|
||||
return normalizeGainLuma([
|
||||
Math.sqrt(ref.r / Math.max(light.r, 1e-4)),
|
||||
Math.sqrt(ref.g / Math.max(light.g, 1e-4)),
|
||||
Math.sqrt(ref.b / Math.max(light.b, 1e-4)),
|
||||
]);
|
||||
}
|
||||
|
||||
if (k < 5500) {
|
||||
// 2500K → 5500K: Warm light → need blue/cyan compensation
|
||||
const factor = (5500 - k) / 3000;
|
||||
r = 1.0 - factor * 0.45;
|
||||
g = 1.0 - factor * 0.15;
|
||||
b = 1.0 + factor * 0.50;
|
||||
} else {
|
||||
// 5500K → 10000K: Cool light → need warm/amber compensation
|
||||
const factor = (k - 5500) / 4500;
|
||||
r = 1.0 + factor * 0.55;
|
||||
g = 1.0 + factor * 0.20;
|
||||
b = 1.0 - factor * 0.40;
|
||||
}
|
||||
// sRGB-encoded colour of a blackbody radiator at K, 0..1 (Tanner Helland's fit
|
||||
// of the Planckian locus; exact enough from 1000K up, and exactly neutral where
|
||||
// the app pins unity). Blue collapses to 0 below ~1900K, hence the guard in the
|
||||
// caller.
|
||||
function planckianSRGB(kelvin: number): { r: number; g: number; b: number } {
|
||||
const t = kelvin / 100;
|
||||
const r = t <= 66 ? 255 : 329.698727446 * Math.pow(t - 60, -0.1332047592);
|
||||
const g =
|
||||
t <= 66
|
||||
? 99.4708025861 * Math.log(t) - 161.1195681661
|
||||
: 288.1221695283 * Math.pow(t - 60, -0.0755148492);
|
||||
const b = t >= 66 ? 255 : t <= 19 ? 0 : 138.5177312231 * Math.log(t - 10) - 305.0447927307;
|
||||
const norm = (v: number) => Math.min(1, Math.max(0, v / 255));
|
||||
return { r: norm(r), g: norm(g), b: norm(b) };
|
||||
}
|
||||
|
||||
return { r, g, b };
|
||||
// Divide a set of channel gains by their own luma: the CAST moves, the exposure
|
||||
// does not. Channels still clip where they must — this only keeps a neutral
|
||||
// pixel sitting at its neutral level.
|
||||
function normalizeGainLuma(gain: number[]): { r: number; g: number; b: number } {
|
||||
const luma = 0.2126 * gain[0] + 0.7152 * gain[1] + 0.0722 * gain[2];
|
||||
const s = luma > 0 ? 1 / luma : 1;
|
||||
return { r: gain[0] * s, g: gain[1] * s, b: gain[2] * s };
|
||||
}
|
||||
|
||||
// Generate a 4x5 ColorMatrix (array of 20 floats) based on base style and
|
||||
@@ -37,8 +64,8 @@ export function getSkiaColorMatrix(baseFilter: BaseFilter, adj: ColorAdjustments
|
||||
const saturation = adj.saturation;
|
||||
const temperature = adj.temperature;
|
||||
const tint = adj.tint;
|
||||
const colorChrome = adj.colorChrome || 'none';
|
||||
const colorChromeBlue = adj.colorChromeBlue || 'none';
|
||||
// colorChrome/colorChromeBlue are deliberately unused here — the chrome
|
||||
// effects run in the tone shader (per-pixel chroma), not the matrix.
|
||||
|
||||
// 1. Start with Identity Matrix
|
||||
let matrix = [
|
||||
@@ -112,19 +139,36 @@ export function getSkiaColorMatrix(baseFilter: BaseFilter, adj: ColorAdjustments
|
||||
0, 0, 0, 1, 0,
|
||||
];
|
||||
} else if (baseFilter === 'leica') {
|
||||
// Leica digital look (M/Q/SL): neutral-but-warm, deep yet open shadows,
|
||||
// restrained saturation with deep reds and true greens — never the punchy
|
||||
// chroma of Velvia or the amber of Classic Neg. A linear matrix cannot do
|
||||
// the smooth highlight rolloff (that's the tone shader's job), so this only
|
||||
// carries the mild contrast/warmth/colour balance half:
|
||||
// - gentle contrast around mid-grey (deep blacks, no lifted blacks)
|
||||
// - slight warm cast in highlights (R up, B down), near-neutral white
|
||||
// - reds a touch deeper, blues slightly muted towards natural skies
|
||||
// - green mostly untouched (Leica greens stay true, not olive)
|
||||
// LEITZ STREETLIFE CLASSIC — the Leica character rather than a saturation
|
||||
// push. What makes a Leica file read as one is the separation BETWEEN the
|
||||
// hues, so the deepening lives in the off-diagonal terms, not in a global
|
||||
// chroma gain:
|
||||
// - the three rows sum to 1.00/1.00/0.995, so greys stay exactly grey and
|
||||
// every saturated colour gains ~5% through channel separation; Velvia
|
||||
// pushes 39%, this stays quiet ("dậm đà, không gắt").
|
||||
// - a small uniform offset deepens the darks: solid shadows that keep their
|
||||
// texture, no lifted black and no crushed toe.
|
||||
// - red up / blue down with green held at 1.00 is the warm European cast:
|
||||
// it lands on skin as warmth, never as the yellow-green a warm gain on
|
||||
// all three channels would give.
|
||||
matrix = [
|
||||
1.08, -0.02, -0.01, 0, -0.025,
|
||||
-0.015, 1.06, -0.01, 0, -0.017,
|
||||
-0.02, -0.015, 1.04, 0, -0.013,
|
||||
1.060, -0.045, -0.010, 0, -0.010,
|
||||
-0.030, 1.055, -0.025, 0, -0.008,
|
||||
-0.025, -0.020, 1.040, 0, -0.005,
|
||||
0, 0, 0, 1, 0,
|
||||
];
|
||||
} else if (baseFilter === 'leica-vivid') {
|
||||
// LEITZ STREETLIFE VIVID — the same Leica channel separation pushed into the
|
||||
// digital era: ~20% of chroma gain (still well under Velvia's 39%, so it
|
||||
// sparkles instead of shouting) on the same warm R>G>B bias CLASSIC has, so
|
||||
// VIVID is a brighter read of one look rather than a second, neutral one.
|
||||
// No positive lift here: the brightness is the sim's own exposure value
|
||||
// (defaultRecipes), which keeps the matrix inside 0..1 and pure white out of
|
||||
// the clip — white lands at most at 1.000 on the red row.
|
||||
matrix = [
|
||||
1.125, -0.100, -0.010, 0, -0.015,
|
||||
-0.055, 1.110, -0.055, 0, -0.005,
|
||||
-0.050, -0.070, 1.100, 0, -0.005,
|
||||
0, 0, 0, 1, 0,
|
||||
];
|
||||
} else if (baseFilter === 'monochrome') {
|
||||
@@ -161,43 +205,31 @@ export function getSkiaColorMatrix(baseFilter: BaseFilter, adj: ColorAdjustments
|
||||
matrix = multiplyMatrices(satMat, matrix);
|
||||
}
|
||||
|
||||
// 4. Color Chrome effect (enhances deep colors without shifting white point)
|
||||
if (baseFilter !== 'monochrome' && colorChrome !== 'none') {
|
||||
const factor = colorChrome === 'strong' ? 0.25 : 0.12;
|
||||
// Boost reds and blues slightly, deep shadows get more saturated
|
||||
const chromeMat = [
|
||||
1 + factor, -factor/2, -factor/2, 0, 0,
|
||||
-factor/2, 1, -factor/2, 0, 0,
|
||||
-factor/2, -factor/2, 1 + factor, 0, 0,
|
||||
0, 0, 0, 1, 0,
|
||||
];
|
||||
matrix = multiplyMatrices(chromeMat, matrix);
|
||||
}
|
||||
|
||||
// 4b. Color Chrome Effect Blue — deep-blue richness boost (blue channel only)
|
||||
if (baseFilter !== 'monochrome' && colorChromeBlue !== 'none') {
|
||||
const factor = colorChromeBlue === 'strong' ? 0.35 : 0.15;
|
||||
const blueMat = [
|
||||
1, 0, 0, 0, 0,
|
||||
0, 1, 0, 0, 0,
|
||||
0, 0, 1 + factor, 0, 0,
|
||||
0, 0, 0, 1, 0,
|
||||
];
|
||||
matrix = multiplyMatrices(blueMat, matrix);
|
||||
}
|
||||
// 4. Color Chrome / Color Chrome FX Blue are NOT here: both deepen only the
|
||||
// already-chromatic pixels, which needs per-pixel chroma (see TONE_SKSL) — a
|
||||
// matrix gain on R/B is a global saturation push that also moves greys, and a
|
||||
// blue-only gain tints the white point. `colorChrome`/`colorChromeBlue` are
|
||||
// consumed by getToneUniforms instead.
|
||||
|
||||
// 5. White Balance (Kelvin Temperature and Tint) — skip for monochrome to prevent color leakage
|
||||
if (baseFilter !== 'monochrome') {
|
||||
const rgbTemp = kelvinToRGB(temperature);
|
||||
// Tint adjustment: -10 is Green (+G), +10 is Magenta (+R, +B)
|
||||
const tintR = tint > 0 ? (tint / 10) * 0.08 : 0;
|
||||
const tintG = tint < 0 ? (-tint / 10) * 0.08 : 0;
|
||||
const tintB = tint > 0 ? (tint / 10) * 0.08 : 0;
|
||||
// Tint is the green↔magenta axis, and it is symmetric: + pushes magenta
|
||||
// (red and blue up, green down), - pushes green (green up, red and blue
|
||||
// down). The old version only ever ADDED a channel (magenta lifted R+B,
|
||||
// green lifted G), so the knob brightened the frame instead of tinting it.
|
||||
// Normalising the product keeps that at a tint shift, not an exposure one.
|
||||
const tintMagenta = (tint / 10) * 0.08;
|
||||
const wbGain = normalizeGainLuma([
|
||||
rgbTemp.r * (1 + tintMagenta),
|
||||
rgbTemp.g * (1 - tintMagenta),
|
||||
rgbTemp.b * (1 + tintMagenta),
|
||||
]);
|
||||
|
||||
const wbMat = [
|
||||
rgbTemp.r + tintR, 0, 0, 0, 0,
|
||||
0, rgbTemp.g + tintG, 0, 0, 0,
|
||||
0, 0, rgbTemp.b + tintB, 0, 0,
|
||||
wbGain.r, 0, 0, 0, 0,
|
||||
0, wbGain.g, 0, 0, 0,
|
||||
0, 0, wbGain.b, 0, 0,
|
||||
0, 0, 0, 1, 0,
|
||||
];
|
||||
matrix = multiplyMatrices(wbMat, matrix);
|
||||
|
||||
@@ -44,11 +44,19 @@ export const FILM_SIMS: FilmSim[] = [
|
||||
{ id: 'sim-astia', name: 'ASTIPES', baseFilter: 'astia', adjustments: { ...DEFAULT_ADJUSTMENTS } },
|
||||
{ id: 'sim-eterna', name: 'ETERNIPES', baseFilter: 'eterna', adjustments: { ...DEFAULT_ADJUSTMENTS } },
|
||||
{ id: 'sim-acros', name: 'ACRIPES', baseFilter: 'monochrome', adjustments: { ...DEFAULT_ADJUSTMENTS } },
|
||||
// Leica digital look (M/Q/SL) is NOT a film stock: neutral-but-warm colour
|
||||
// with restrained saturation, deep open shadows and gentle highlight rolloff.
|
||||
// Matrix-only here (like every sim) — the tonal half lives in the leica
|
||||
// matrix (mild contrast + warm neutrality); users stack DR/tone on top.
|
||||
{ id: 'sim-leica', name: 'LEITZ STREETLIFE', baseFilter: 'leica', adjustments: { ...DEFAULT_ADJUSTMENTS } },
|
||||
// Leica digital look (M/Q/SL) is not a film stock, so it ships as its own
|
||||
// pair instead of one look that has to compromise:
|
||||
// CLASSIC — the stock character: deep solid darks, natural shadows, rich but
|
||||
// quiet colour. Its micro-contrast is CLARITY (a local unsharp mask in both
|
||||
// renderers), never a global saturation boost, and a whisker of grain
|
||||
// keeps it feeling like film rather than digital sharpness.
|
||||
// VIVID — the same Leica separation opened up for the digital era: lifted
|
||||
// exposure, lifted shadows, a little colour. It reads bright and flattering
|
||||
// straight off the shutter, no editing pass needed.
|
||||
// Both share the 'leica'/'leica-vivid' matrices; the tonal half is the sim's
|
||||
// own adjustments, which every apply path now carries into the panel.
|
||||
{ id: 'sim-leica', name: 'LEITZ STREETLIFE CLASSIC', baseFilter: 'leica', adjustments: { ...DEFAULT_ADJUSTMENTS, clarity: 2, grain: 1 } },
|
||||
{ id: 'sim-leica-vivid', name: 'LEITZ STREETLIFE VIVID', baseFilter: 'leica-vivid', adjustments: { ...DEFAULT_ADJUSTMENTS, exposure: 2, saturation: 2, clarity: 2, shadow: 1, highlight: -1 } },
|
||||
];
|
||||
|
||||
// Film sim → full Recipe (never persisted; sims are applied on the fly).
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||
import { FrameId } from '../types';
|
||||
import { IS_PRO_BUILD } from '../provariant';
|
||||
|
||||
// Lite (free, forever) vs PRO (one unlock key). The gate is on the FILE, not on
|
||||
// the preview: a Lite user may pick any preset, frame or watermark, compose
|
||||
@@ -18,6 +19,10 @@ export const PRO_FRAMES: FrameId[] = ['classic-white', 'polaroid', 'wallframe'];
|
||||
export const isFreeRecipe = (id: string | null | undefined): boolean =>
|
||||
!id || id.startsWith('sim-');
|
||||
|
||||
// Lite may store its own recipes, up to this many: creating and editing them is
|
||||
// free, deleting one is PRO — so a Lite list only ever grows to this size.
|
||||
export const LITE_RECIPE_LIMIT = 3;
|
||||
|
||||
// Burned into every Lite export, bottom-right of the photo/frame area. Fractions
|
||||
// of the stamp canvas, same space the custom mark uses.
|
||||
export const LITE_MARK = {
|
||||
@@ -43,8 +48,11 @@ export function proLookInUse(look: Look): string | null {
|
||||
return null;
|
||||
}
|
||||
|
||||
// The Pro apk is the product: it is unlocked by being the paid build, and its
|
||||
// SETTINGS sheet never shows a key field. The Lite apk unlocks the same features
|
||||
// with a keygen key, which is what a buyer of the cheap tier gets instead.
|
||||
export const loadPro = async (): Promise<boolean> =>
|
||||
(await AsyncStorage.getItem(PRO_KEY)) === '1';
|
||||
IS_PRO_BUILD || (await AsyncStorage.getItem(PRO_KEY)) === '1';
|
||||
|
||||
// ---- unlock key -------------------------------------------------------------
|
||||
// Offline product key: 12 payload chars + 4 checksum chars, any punctuation
|
||||
|
||||
+59
-35
@@ -5,12 +5,13 @@ import * as MediaLibrary from 'expo-media-library/legacy';
|
||||
import { Asset } from 'expo-asset';
|
||||
import { Recipe, GPSInfo, FrameId, AspectRatio, ASPECT_RATIO_W_H, CropRect } from '../types';
|
||||
import { getSkiaColorMatrix, applyExposureGain } from './colorUtils';
|
||||
import { TONE_SKSL, getToneUniforms, toneIsActive, toneUniformArray } from './toneShader';
|
||||
import { TONE_SKSL, getToneUniforms, toneIsActive, toneUniformArray, GLOW_SKSL, glowUniformArray } from './toneShader';
|
||||
import { CINEMA_SKSL, getCinemaUniforms, cinemaIsActive } from './cinemaShader';
|
||||
import { drawFrameOnCanvas, polaroidLayout, POLAROID_CARD, POLAROID_WIN_W, wallframeLayout, WALLFRAME_W, WALLFRAME_H, WALLFRAME_LAND_W, WALLFRAME_LAND_H } from './frameUtils';
|
||||
import { patchJpegDpi } from './jpegDpi';
|
||||
import { writeJpegExif } from './exifWrite';
|
||||
import { nextPhotoPath, readPhotoBytes, getDeviceIdentity } from './photoMeta';
|
||||
import { ensureMediaLibraryPermission } from './mediaPermission';
|
||||
import RecipescamExport from '../../modules/recipescam-export';
|
||||
|
||||
const stripScheme = (uri: string) => uri.replace(/^file:\/\//, '');
|
||||
@@ -341,49 +342,72 @@ export async function processAndExportPhoto(
|
||||
|
||||
// 5b. Highlight Diffusion Filter (HDF): bloom on the bright areas only —
|
||||
// the software twin of the lens' built-in diffusion filter, Photoshopped
|
||||
// exactly as described: extract the highlights with a hard contrast curve,
|
||||
// Gaussian-blur that selection (radius 15..50px), Screen it back over the
|
||||
// photo at reduced opacity. Screen against a near-black pixel is a no-op, so
|
||||
// shadows stay where they were while lit areas spread their light.
|
||||
// exactly as described: extract the highlights with a Bright Pass Filter on
|
||||
// the LUMINANCE, Gaussian-blur that selection (radius 15..50px), Screen it
|
||||
// back over the photo at reduced opacity. Below the knee the pass returns
|
||||
// exact 0.0 and Screen against black is a no-op, so the shadows stay where
|
||||
// they were while only the lit areas spread their light.
|
||||
// One 0..10 knob drives both the blur radius and the opacity, and the radius
|
||||
// is a fraction of the width so any source size blooms alike.
|
||||
// ponytail: split into radius/amount knobs only if the 1D ramp reads coarse.
|
||||
if ((adjustments.hdf ?? 0) > 0) {
|
||||
const strength = (adjustments.hdf ?? 0) / 10;
|
||||
const sigma = width * (0.004 + 0.015 * strength); // ~10..50px at 2560 wide
|
||||
// Highlight extraction: out = 2.5*in - 1.5 -> black below 0.6, speculars
|
||||
// saturate white. Composed on TOP of the recipe matrix so a monochrome look
|
||||
// glows white instead of leaking the original colours back through.
|
||||
const hiMatrix = [
|
||||
2.5, 0, 0, 0, -1.5,
|
||||
0, 2.5, 0, 0, -1.5,
|
||||
0, 0, 2.5, 0, -1.5,
|
||||
0, 0, 0, 1, 0,
|
||||
];
|
||||
// The order is load-bearing and NOT the paint's: a paint color filter runs
|
||||
// after the paint's image filter, i.e. the blur would smear the recipe
|
||||
// matrix into the selection instead of pulling the highlights out first —
|
||||
// measured as a flat +6/255 haze in the shadows with no glow on the
|
||||
// speculars. Chain both as image filters instead (inner runs first).
|
||||
const selection = Skia.ImageFilter.MakeColorFilter(
|
||||
Skia.ColorFilter.MakeCompose(Skia.ColorFilter.MakeMatrix(hiMatrix), colorFilter),
|
||||
null
|
||||
);
|
||||
// Bright pass by LUMINANCE (see GLOW_SKSL). The old per-channel matrix let
|
||||
// a saturated blue through untinted and left a coloured smear in the
|
||||
// darks; one luma knee + one gain kills both.
|
||||
let glowEffect: ReturnType<typeof Skia.RuntimeEffect.Make> = null;
|
||||
try {
|
||||
glowEffect = Skia.RuntimeEffect.Make(GLOW_SKSL);
|
||||
} catch (e) {
|
||||
console.error('GLOW_SKSL compile failed - per-channel fallback: ' + e);
|
||||
}
|
||||
const srcShader =
|
||||
paintShader ??
|
||||
skImage.makeShaderOptions(TileMode.Clamp, TileMode.Clamp, FilterMode.Linear, MipmapMode.None);
|
||||
const glowShader =
|
||||
glowEffect != null ? glowEffect.makeShaderWithChildren(glowUniformArray(), [srcShader]) : null;
|
||||
const glowPaint = Skia.Paint();
|
||||
glowPaint.setBlendMode(BlendMode.Screen);
|
||||
glowPaint.setAlphaf(0.15 + 0.35 * strength);
|
||||
glowPaint.setImageFilter(
|
||||
Skia.ImageFilter.MakeCompose(
|
||||
Skia.ImageFilter.MakeBlur(sigma, sigma, TileMode.Clamp, null),
|
||||
selection
|
||||
)
|
||||
);
|
||||
// Same source the base draw used, so the glow sits on the processed pixels.
|
||||
if (paintShader) {
|
||||
glowPaint.setShader(paintShader);
|
||||
if (glowShader != null) {
|
||||
glowPaint.setShader(glowShader);
|
||||
// Recipe matrix stays where it always was — inside the filter chain,
|
||||
// under the blur (inner runs first) — so the bloom lands on GRADED
|
||||
// pixels: a monochrome recipe glows white instead of leaking the
|
||||
// original colours back through the highlights.
|
||||
glowPaint.setImageFilter(
|
||||
Skia.ImageFilter.MakeCompose(
|
||||
Skia.ImageFilter.MakeBlur(sigma, sigma, TileMode.Clamp, null),
|
||||
Skia.ImageFilter.MakeColorFilter(colorFilter, null)
|
||||
)
|
||||
);
|
||||
canvas.drawRect(Skia.XYWHRect(0, 0, width, height), glowPaint);
|
||||
} else {
|
||||
canvas.drawImage(skImage, 0, 0, glowPaint);
|
||||
// GLOW_SKSL unavailable: keep the old per-channel curve (composed on TOP
|
||||
// of the recipe matrix, the order that keeps a monochrome look white).
|
||||
// A leaky bloom beats no bloom at all.
|
||||
const hiMatrix = [
|
||||
2.5, 0, 0, 0, -1.5,
|
||||
0, 2.5, 0, 0, -1.5,
|
||||
0, 0, 2.5, 0, -1.5,
|
||||
0, 0, 0, 1, 0,
|
||||
];
|
||||
glowPaint.setImageFilter(
|
||||
Skia.ImageFilter.MakeCompose(
|
||||
Skia.ImageFilter.MakeBlur(sigma, sigma, TileMode.Clamp, null),
|
||||
Skia.ImageFilter.MakeColorFilter(
|
||||
Skia.ColorFilter.MakeCompose(Skia.ColorFilter.MakeMatrix(hiMatrix), colorFilter),
|
||||
null
|
||||
)
|
||||
)
|
||||
);
|
||||
if (paintShader) {
|
||||
glowPaint.setShader(paintShader);
|
||||
canvas.drawRect(Skia.XYWHRect(0, 0, width, height), glowPaint);
|
||||
} else {
|
||||
canvas.drawImage(skImage, 0, 0, glowPaint);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -747,8 +771,8 @@ export async function processAndExportPhoto(
|
||||
// the saved copy. ponytail: old exports are NOT retired here anymore — the
|
||||
// in-app photo history still references their cache files for the swipe
|
||||
// viewer; the OS cache purge reclaims them eventually.
|
||||
const mediaPermission = await MediaLibrary.requestPermissionsAsync();
|
||||
if (mediaPermission.granted) {
|
||||
const mediaGranted = await ensureMediaLibraryPermission();
|
||||
if (mediaGranted) {
|
||||
// DCIM/Camera (stock camera location) first: expo-media-library can only
|
||||
// reach DCIM (root) or Pictures/Camera, so the native module does the
|
||||
// MediaStore insert. Falls back to the plain expo save if that fails.
|
||||
|
||||
@@ -11,9 +11,27 @@ export async function requestLocationPermissions(): Promise<boolean> {
|
||||
}
|
||||
}
|
||||
|
||||
// The OS permission screen takes the window focus for a moment and the camera
|
||||
// session goes down with it, so a capture that asks for location comes back
|
||||
// "Camera is closed" — one ask per shot turned a burst into failed frames.
|
||||
// Check the status (no UI, cheap) and raise the dialog at most once per run.
|
||||
let locationAsked = false;
|
||||
|
||||
async function ensureLocationPermission(): Promise<boolean> {
|
||||
try {
|
||||
if ((await Location.getForegroundPermissionsAsync()).granted) return true;
|
||||
if (locationAsked) return false;
|
||||
locationAsked = true;
|
||||
return (await Location.requestForegroundPermissionsAsync()).status === 'granted';
|
||||
} catch (error) {
|
||||
console.error('Error requesting location permissions:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function getCurrentGPS(): Promise<GPSInfo | null> {
|
||||
try {
|
||||
const hasPermission = await requestLocationPermissions();
|
||||
const hasPermission = await ensureLocationPermission();
|
||||
if (!hasPermission) return null;
|
||||
|
||||
const location = await Location.getCurrentPositionAsync({
|
||||
@@ -67,8 +85,9 @@ export async function reverseGeocode(latitude: number, longitude: number): Promi
|
||||
// Foreground permission is not optional here: expo-location's Android
|
||||
// geocoder throws LocationUnauthorizedException without it. A photo picked
|
||||
// before the camera ever asked for location therefore resolved to no name at
|
||||
// all and the stamp fell back to "STREET VIEW" — so ask before every call.
|
||||
await requestLocationPermissions();
|
||||
// all and the stamp fell back to "STREET VIEW" — so make sure it is held
|
||||
// before every call (same once-per-run ask as getCurrentGPS).
|
||||
await ensureLocationPermission();
|
||||
try {
|
||||
const geo = await Location.reverseGeocodeAsync({ latitude, longitude });
|
||||
if (geo && geo.length > 0) return localityName(geo[0]);
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import * as MediaLibrary from 'expo-media-library/legacy';
|
||||
|
||||
// Raising the media permission opens the OS permission screen, which takes the
|
||||
// window focus for a moment — and the camera session goes down with it. Asking
|
||||
// once per export therefore turned a burst into frames that came back
|
||||
// "ImageCaptureException: Camera is closed". Check the status (no UI, cheap)
|
||||
// and raise the dialog at most once per run.
|
||||
let asked = false;
|
||||
|
||||
export async function ensureMediaLibraryPermission(): Promise<boolean> {
|
||||
try {
|
||||
if ((await MediaLibrary.getPermissionsAsync()).granted) return true;
|
||||
if (asked) return false;
|
||||
asked = true;
|
||||
return (await MediaLibrary.requestPermissionsAsync()).granted;
|
||||
} catch (e) {
|
||||
console.warn('Media library permission check failed:', e);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,7 @@ import { Recipe, GPSInfo, FrameId, AspectRatio, ColorAdjustments } from '../type
|
||||
import { getSkiaColorMatrix, applyExposureGain } from './colorUtils';
|
||||
import { getToneUniforms } from './toneShader';
|
||||
import { getCinemaUniforms } from './cinemaShader';
|
||||
import { ensureMediaLibraryPermission } from './mediaPermission';
|
||||
import { nextPhotoPath, stampExifOnFile } from './photoMeta';
|
||||
|
||||
export interface NativeExportResult {
|
||||
@@ -65,8 +66,11 @@ export async function processAndExportPhotoNative(
|
||||
adjustments.exposureCompensation ?? 0
|
||||
);
|
||||
// Base filter is passed so a stock's own tone pass is picked up, but the
|
||||
// Kotlin side only reads toneDr/Hl/Sh — the split-tone RGB (Classic Neg) is
|
||||
// preview/engine only. Nothing in the app calls nativeExport (dev probe).
|
||||
// Kotlin side only reads toneDr/Hl/Sh — the split-tone RGB (Classic Neg)
|
||||
// and the Color Chrome/Chrome-Blue depths are preview/engine only, so a
|
||||
// native-export build silently drops those three. ponytail: port them into
|
||||
// applyTone() when the native path stops being a dev probe
|
||||
// (EXPO_PUBLIC_NATIVE_EXPORT is not inlined into a release HBC bundle).
|
||||
const tone = getToneUniforms(adjustments, recipe.baseFilter);
|
||||
const cinema = getCinemaUniforms(recipe.cinema);
|
||||
// Final screen sharpen (engine #9): the sharpening knob (0..10) overrides
|
||||
@@ -114,8 +118,8 @@ export async function processAndExportPhotoNative(
|
||||
sourceUri
|
||||
);
|
||||
|
||||
const mediaPermission = await MediaLibrary.requestPermissionsAsync();
|
||||
if (mediaPermission.granted) {
|
||||
const mediaGranted = await ensureMediaLibraryPermission();
|
||||
if (mediaGranted) {
|
||||
try {
|
||||
const asset = await MediaLibrary.createAssetAsync(exportFile);
|
||||
return { uri: exportFile, savedToLibrary: true, assetId: asset.id };
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
import { Recipe, BaseFilter, FrameId, ColorAdjustments } from '../types';
|
||||
import { DEFAULT_ADJUSTMENTS, FILM_SIMS } from './defaultRecipes';
|
||||
|
||||
// Shared-recipe file: a tiny XML envelope around a scrambled hex payload of the
|
||||
// recipe JSON, so a shared recipe imports back into the app on another phone.
|
||||
//
|
||||
// ponytail: obfuscation, not cryptography — the key ships inside the app and
|
||||
// the keystream is a plain xorshift. It stops hand-editing and casual peeking,
|
||||
// which is all a shared look needs; swap in expo-crypto/AES at the same two
|
||||
// functions if the format ever has to resist a determined attacker.
|
||||
const KEY = 'RecipesCam::recipe-share::v1';
|
||||
const ALGORITHM = 'xor16-v1';
|
||||
|
||||
const BASE_FILTERS: BaseFilter[] = [...FILM_SIMS.map((s) => s.baseFilter), 'none'];
|
||||
const FRAMES: FrameId[] = ['none', 'polaroid', 'classic-white', 'cinematic', 'wallframe'];
|
||||
|
||||
const hash = (s: string): number => {
|
||||
let h = 0x811c9dc5;
|
||||
for (let i = 0; i < s.length; i++) {
|
||||
h ^= s.charCodeAt(i);
|
||||
h = Math.imul(h, 0x01000193);
|
||||
}
|
||||
return h >>> 0;
|
||||
};
|
||||
|
||||
// xorshift32: one 16-bit word of keystream per call.
|
||||
const stream = (seed: number) => {
|
||||
let s = seed >>> 0 || 0x9e3779b9;
|
||||
return () => {
|
||||
s ^= s << 13;
|
||||
s >>>= 0;
|
||||
s ^= s >>> 17;
|
||||
s ^= s << 5;
|
||||
s >>>= 0;
|
||||
return s & 0xffff;
|
||||
};
|
||||
};
|
||||
|
||||
// 16-bit code units, hex-encoded: no encoder needed, every UTF-16 char (names
|
||||
// with Vietnamese diacritics included) survives the round trip.
|
||||
const scramble = (text: string, salt: number): string => {
|
||||
const k = stream(hash(`${KEY}|${salt}`));
|
||||
let out = '';
|
||||
for (let i = 0; i < text.length; i++) out += (text.charCodeAt(i) ^ k()).toString(16).padStart(4, '0');
|
||||
return out;
|
||||
};
|
||||
|
||||
const unscramble = (hex: string, salt: number): string => {
|
||||
if (hex.length % 4 !== 0) throw new Error('Recipe file is damaged.');
|
||||
const k = stream(hash(`${KEY}|${salt}`));
|
||||
let out = '';
|
||||
for (let i = 0; i < hex.length; i += 4) out += String.fromCharCode(parseInt(hex.slice(i, i + 4), 16) ^ k());
|
||||
return out;
|
||||
};
|
||||
|
||||
// The shareable half of a recipe: never the local id, and only known fields, so
|
||||
// a file from a newer version cannot smuggle anything into storage.
|
||||
export function exportRecipeXml(recipe: Recipe): string {
|
||||
const shareable = {
|
||||
name: recipe.name,
|
||||
baseFilter: recipe.baseFilter,
|
||||
adjustments: recipe.adjustments,
|
||||
frameId: recipe.frameId,
|
||||
useGeotag: recipe.useGeotag,
|
||||
};
|
||||
const salt = Math.floor(Math.random() * 0xffffffff) >>> 0;
|
||||
return [
|
||||
'<?xml version="1.0" encoding="UTF-8"?>',
|
||||
`<recipescam-recipe version="1" algorithm="${ALGORITHM}" salt="${salt.toString(16)}">`,
|
||||
` <payload>${scramble(JSON.stringify(shareable), salt)}</payload>`,
|
||||
'</recipescam-recipe>',
|
||||
'',
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
// Throws with a user-readable reason. Fields are validated and clamped to what
|
||||
// this build understands: a recipe from a newer app imports as best it can
|
||||
// instead of poisoning the store.
|
||||
export function importRecipeXml(xml: string): Omit<Recipe, 'id' | 'isCustom'> {
|
||||
const salt = /salt="([0-9a-fA-F]+)"/.exec(xml)?.[1];
|
||||
const payload = /<payload>([0-9a-fA-F\s]+)<\/payload>/.exec(xml)?.[1]?.replace(/\s+/g, '');
|
||||
if (!salt || !payload) throw new Error('Not a RecipesCam recipe file.');
|
||||
|
||||
let raw: any;
|
||||
try {
|
||||
raw = JSON.parse(unscramble(payload, parseInt(salt, 16)));
|
||||
} catch {
|
||||
raw = null;
|
||||
}
|
||||
if (!raw || typeof raw !== 'object' || typeof raw.name !== 'string' || !raw.adjustments || typeof raw.adjustments !== 'object') {
|
||||
throw new Error('Recipe file is damaged or from another app.');
|
||||
}
|
||||
|
||||
const name = raw.name.trim().slice(0, 40) || 'Imported recipe';
|
||||
const baseFilter: BaseFilter = BASE_FILTERS.includes(raw.baseFilter) ? raw.baseFilter : 'none';
|
||||
const adjustments: ColorAdjustments = { ...DEFAULT_ADJUSTMENTS, ...(raw.adjustments as Partial<ColorAdjustments>) };
|
||||
const frameId: FrameId = FRAMES.includes(raw.frameId) ? raw.frameId : 'none';
|
||||
return { name, baseFilter, adjustments, frameId, useGeotag: !!raw.useGeotag };
|
||||
}
|
||||
+114
-3
@@ -48,6 +48,8 @@ uniform float shTb;
|
||||
uniform float hlTr;
|
||||
uniform float hlTg;
|
||||
uniform float hlTb;
|
||||
uniform float cc;
|
||||
uniform float ccb;
|
||||
vec4 main(vec2 xy) {
|
||||
vec4 c = src.eval(xy);
|
||||
vec3 rgb = clamp(c.rgb, 0.0, 1.0);
|
||||
@@ -69,6 +71,31 @@ vec4 main(vec2 xy) {
|
||||
// their own tint, so the two ends of the curve can drift opposite ways
|
||||
// (Classic Neg: green-cyan darks, warm brights) without touching mid-greys.
|
||||
rgb = clamp(rgb + vec3(shTr, shTg, shTb) * shMask + vec3(hlTr, hlTg, hlTb) * hlMask, 0.0, 1.0);
|
||||
// Color Chrome / Color Chrome FX Blue: the two stock-dialed colour effects
|
||||
// DEEPEN what is already chromatic and leave neutrals exactly where they are
|
||||
// (Fuji: "deeper tone in highly saturated colour"; FX Blue does it for the
|
||||
// blue/cyan side only). Both therefore need per-pixel chroma — a 4x5 colour
|
||||
// matrix is one linear map, so any gain it applies also moves greys, and a
|
||||
// blue-only gain drags the whole white point.
|
||||
float mxc = max(max(rgb.r, rgb.g), rgb.b);
|
||||
float mnc = min(min(rgb.r, rgb.g), rgb.b);
|
||||
// Chroma ratio with a small floor: a near-black pixel with a hair of cast
|
||||
// has ratio 1.0 but no colour to deepen, and must stay put.
|
||||
float ccChroma = (mxc - mnc) / max(mxc, 0.10);
|
||||
// Color Chrome rides the chroma itself: a muted colour barely moves, a vivid
|
||||
// one gains density. The 0.25 knee keeps skin, haze and pastels untouched.
|
||||
float ccMask = cc * smoothstep(0.25, 0.85, ccChroma);
|
||||
// FX Blue: only where blue clearly leads red AND green (so magenta/purple
|
||||
// stay out), and richest in a bright blue — a dark blue has no tonality left
|
||||
// to deepen.
|
||||
float ccbBlue = clamp((rgb.b - rgb.r) * 2.0, 0.0, 1.0) * clamp((rgb.b - 0.5 * (rgb.r + rgb.g) + 0.05) * 3.0, 0.0, 1.0);
|
||||
float ccbMask = ccb * ccbBlue * smoothstep(0.15, 0.60, ccChroma) * smoothstep(0.20, 0.70, t);
|
||||
float deep = clamp(ccMask + ccbMask, 0.0, 1.0);
|
||||
// Density = lightness down with the colour difference riding along, so hue is
|
||||
// preserved and the colour cannot collapse toward black (same reason the tone
|
||||
// curve above keeps chroma). A touch of chroma is given up as it deepens.
|
||||
float l3 = dot(rgb, vec3(0.2126, 0.7152, 0.0722));
|
||||
rgb = clamp(vec3(l3 * (1.0 - 0.28 * deep)) + (rgb - vec3(l3)) * (1.0 - 0.10 * deep), 0.0, 1.0);
|
||||
// Vibrance: push the LESS-saturated pixels harder than the vivid ones.
|
||||
float l2 = dot(rgb, vec3(0.2126, 0.7152, 0.0722));
|
||||
float mx = max(max(rgb.r, rgb.g), rgb.b);
|
||||
@@ -79,6 +106,69 @@ vec4 main(vec2 xy) {
|
||||
}
|
||||
`;
|
||||
|
||||
// Bright Pass Filter for the LIGHT GLOW pass (HDF), SkSL over a child image
|
||||
// shader — the pattern TONE_SKSL above already proved on device.
|
||||
//
|
||||
// Per channel the old 2.5*in-1.5 curve only zeroed a channel that was dark
|
||||
// *itself*: a saturated blue (B = 1.0) came out of it fully lit, so a dark blue
|
||||
// shadow bloomed and a dark saturated colour smeared its hue into the darks.
|
||||
// Photoshop's Bright Pass filters on the LUMINANCE instead: one knee decides how
|
||||
// much light a pixel carries, and one gain scales all three channels, so below
|
||||
// the knee the output is exactly 0.0 (Screen against black = no-op, the shadows
|
||||
// are untouched) and above it every channel keeps its ratio — the hue cannot
|
||||
// drift, only the brightness blooms.
|
||||
//
|
||||
// Knee t0..t1 = 0.55..0.85: the soft end of the old hard 0.6 cut (2.5*0.6-1.5 =
|
||||
// 0), wide enough that a specular ramps in instead of clipping on/off.
|
||||
export const GLOW_T0 = 0.55;
|
||||
export const GLOW_T1 = 0.85;
|
||||
|
||||
export const GLOW_SKSL = `
|
||||
uniform shader src;
|
||||
uniform float t0;
|
||||
uniform float t1;
|
||||
vec4 main(vec2 xy) {
|
||||
vec4 c = src.eval(xy);
|
||||
float luma = dot(clamp(c.rgb, 0.0, 1.0), vec3(0.2126, 0.7152, 0.0722));
|
||||
return vec4(c.rgb * smoothstep(t0, t1, luma), c.a);
|
||||
}
|
||||
`;
|
||||
|
||||
// Flat uniform buffer for `makeShaderWithChildren` — same order as GLOW_SKSL's
|
||||
// declarations (t0, t1).
|
||||
export function glowUniformArray(): number[] {
|
||||
'worklet';
|
||||
return [GLOW_T0, GLOW_T1];
|
||||
}
|
||||
|
||||
// Same values for the declarative <Shader> path, which indexes uniforms by
|
||||
// NAME (a flat array is only valid for the JS makeShaderWithChildren API).
|
||||
export const GLOW_UNIFORMS = { t0: GLOW_T0, t1: GLOW_T1 };
|
||||
|
||||
// CLARITY (positive): unsharp 3x3 with epsilon 0 — the kernel export pass 4
|
||||
// builds with MakeMatrixConvolution, re-expressed as a plain shader because RN
|
||||
// Skia 2.6 exposes no convolution image filter to the declarative JSX writer.
|
||||
// `px` is one ORIGINAL image pixel expressed in the caller's canvas units, so
|
||||
// the preview, the camera worklet and the file all sharpen at the same radius.
|
||||
export const CLARITY_SKSL = `
|
||||
uniform shader src;
|
||||
uniform float a;
|
||||
uniform float2 px;
|
||||
vec4 main(vec2 xy) {
|
||||
vec4 c = src.eval(xy);
|
||||
vec4 s = src.eval(xy + float2(0.0, -px.y))
|
||||
+ src.eval(xy + float2(0.0, px.y))
|
||||
+ src.eval(xy + float2(-px.x, 0.0))
|
||||
+ src.eval(xy + float2( px.x, 0.0));
|
||||
return vec4(clamp(c.rgb * (1.0 + 4.0 * a) - a * s.rgb, 0.0, 1.0), c.a);
|
||||
}
|
||||
`;
|
||||
|
||||
// Named uniforms for <Shader uniforms>, same names as CLARITY_SKSL declares.
|
||||
export function clarityUniforms(a: number, pxX: number, pxY: number) {
|
||||
return { a, px: [pxX, pxY] };
|
||||
}
|
||||
|
||||
export interface ToneUniforms {
|
||||
// All zero → no tone adjustment needed (caller can skip the shader pass).
|
||||
dr: number; // 0..1
|
||||
@@ -87,6 +177,8 @@ export interface ToneUniforms {
|
||||
vib: number; // -1..1 (adjustments.vibrance / 10)
|
||||
shT: [number, number, number]; // shadow split-tone RGB bias, -1..1
|
||||
hlT: [number, number, number]; // highlight split-tone RGB bias, -1..1
|
||||
cc: number; // 0..1 Color Chrome depth (0 = 'none')
|
||||
ccb: number; // 0..1 Color Chrome FX Blue depth (0 = 'none')
|
||||
}
|
||||
|
||||
// Per-stock tone pass. Fuji's Classic stocks are not a plain colour matrix:
|
||||
@@ -108,13 +200,30 @@ export function getToneUniforms(adj: ColorAdjustments, baseFilter?: BaseFilter):
|
||||
const film = (baseFilter && FILM_TONE[baseFilter]) || {};
|
||||
const shT: [number, number, number] = film.shT ?? [0, 0, 0];
|
||||
const hlT: [number, number, number] = film.hlT ?? [0, 0, 0];
|
||||
return { dr, hl: hl + (film.hl ?? 0), sh: sh + (film.sh ?? 0), vib, shT, hlT };
|
||||
// Color Chrome depth per stop of the UI's none/weak/strong. A chrome set is a
|
||||
// monochrome look, so both are forced off there: the effect is colour-only
|
||||
// (the preview/export matrix skips them for monochrome for the same reason).
|
||||
const colour = baseFilter !== 'monochrome';
|
||||
const chromeDepth = (v: ColorAdjustments['colorChrome'] | undefined) =>
|
||||
!colour || v === 'none' || v == null ? 0 : v === 'strong' ? 0.9 : 0.45;
|
||||
const blueDepth = (v: ColorAdjustments['colorChromeBlue'] | undefined) =>
|
||||
!colour || v === 'none' || v == null ? 0 : v === 'strong' ? 1.0 : 0.5;
|
||||
return {
|
||||
dr,
|
||||
hl: hl + (film.hl ?? 0),
|
||||
sh: sh + (film.sh ?? 0),
|
||||
vib,
|
||||
shT,
|
||||
hlT,
|
||||
cc: chromeDepth(adj.colorChrome),
|
||||
ccb: blueDepth(adj.colorChromeBlue),
|
||||
};
|
||||
}
|
||||
|
||||
// Flat uniform buffer for `makeShaderWithChildren` / `<Shader uniforms>` — the
|
||||
// order must match TONE_SKSL's declarations.
|
||||
export function toneUniformArray(u: ToneUniforms): number[] {
|
||||
return [u.dr, u.hl, u.sh, u.vib, u.shT[0], u.shT[1], u.shT[2], u.hlT[0], u.hlT[1], u.hlT[2]];
|
||||
return [u.dr, u.hl, u.sh, u.vib, u.shT[0], u.shT[1], u.shT[2], u.hlT[0], u.hlT[1], u.hlT[2], u.cc, u.ccb];
|
||||
}
|
||||
|
||||
export function toneIsActive(u: ToneUniforms): boolean {
|
||||
@@ -128,6 +237,8 @@ export function toneIsActive(u: ToneUniforms): boolean {
|
||||
u.shT[2] !== 0 ||
|
||||
u.hlT[0] !== 0 ||
|
||||
u.hlT[1] !== 0 ||
|
||||
u.hlT[2] !== 0
|
||||
u.hlT[2] !== 0 ||
|
||||
u.cc !== 0 ||
|
||||
u.ccb !== 0
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user