Sell PRO as an in-app unlock, take shared photos, and let FX run negative
- One free Play app (com.locphamtran.recipescamera) with an expo-iap PRO unlock, replacing the paid apk for the store channel - Android SEND/SEND_MULTIPLE share target feeds photos into the editor, on cold start and on resume - BACK button in the TopBar and press-and-hold PEEK replace the chip row - NOISE REDUCTION and SHARPENING accept -10: a negative SHARPENING softens the frame before the grain pass, a negative NOISE REDUCTION re-grains it, in the Skia engine, the live preview and the Kotlin export path alike
This commit is contained in:
@@ -16,6 +16,7 @@ import ToolRail, { TabId } from './src/components/ToolRail';
|
||||
import SettingsModal from './src/components/SettingsModal';
|
||||
import PhotoViewerModal from './src/components/PhotoViewerModal';
|
||||
import LibraryPickerModal from './src/components/LibraryPickerModal';
|
||||
import ProModal from './src/components/ProModal';
|
||||
|
||||
import { Recipe, ColorAdjustments, CustomWatermark, GpsWatermark, FrameId, GPSInfo, AspectRatio, MeterMode, PhotoEntry, ShutterSound, StartupMode, CropRatio, CropRect, CROP_W_H, DEFAULT_CROP_RECT } from './src/types';
|
||||
import { getAllRecipes, saveCustomRecipe, updateCustomRecipe, deleteCustomRecipe, getFavoriteIds, setFavoriteId } from './src/utils/storageUtils';
|
||||
@@ -26,8 +27,10 @@ import { processAndExportPhoto } from './src/utils/exportEngine';
|
||||
import { processAndExportPhotoNative } from './src/utils/nativeExport';
|
||||
import { exportRecipeXml, importRecipeXml } from './src/utils/recipeShare';
|
||||
import { PARAM_DEFS } from './src/utils/paramDefs';
|
||||
import { IS_PRO, LITE_FAVORITE_LIMIT, LITE_MARK, LITE_RECIPE_LIMIT, proLookInUse } from './src/utils/entitlement';
|
||||
import { LITE_FAVORITE_LIMIT, LITE_MARK, LITE_RECIPE_LIMIT, proLookInUse } from './src/utils/entitlement';
|
||||
import { initProUnlock, useProUnlock } from './src/utils/proUnlock';
|
||||
import UltraWide from './modules/recipescam-ultrawide';
|
||||
import RecipescamExport from './modules/recipescam-export';
|
||||
|
||||
// @ts-ignore
|
||||
import './global.css';
|
||||
@@ -97,8 +100,17 @@ export default function App() {
|
||||
};
|
||||
}, []);
|
||||
const [recipes, setRecipes] = useState<Recipe[]>([]);
|
||||
// LITE (free) vs PRO (the paid build): fixed for the life of the install.
|
||||
const pro = IS_PRO;
|
||||
// LITE (free) vs PRO: the PRO apk is born owned, the free one buys the tier
|
||||
// through Google Play. Runtime state, read here and passed down.
|
||||
const pro = useProUnlock().owned;
|
||||
// The paywall sheet: opened by the SETTINGS plan row and by every alert that
|
||||
// names a PRO feature, so the refusal always comes with a way to pay.
|
||||
const [showPro, setShowPro] = useState(false);
|
||||
// Ask the store what this account owns, once, at startup. Until it answers,
|
||||
// the cached answer stands (see src/utils/proUnlock.ts).
|
||||
useEffect(() => {
|
||||
initProUnlock();
|
||||
}, []);
|
||||
// Lite stores up to LITE_RECIPE_LIMIT recipes of its own; past that the save
|
||||
// paths ask for PRO. Deleting one is PRO as well, so a Lite list only grows.
|
||||
const customRecipeCount = recipes.filter((r) => r.isCustom).length;
|
||||
@@ -158,8 +170,12 @@ export default function App() {
|
||||
// Draft edit: toggling the chip OFF also drops any accepted stamp, so a
|
||||
// switched-off watermark can never come back in an export.
|
||||
const handleUpdateCustomWm = (wm: CustomWatermark) => {
|
||||
setCustomWm(wm);
|
||||
if (!wm.enabled) setCustomWmApplied({ ...DEFAULT_CUSTOM_WM });
|
||||
// Typing coalesces into one step per pause; a chip (colour, size, ON/OFF)
|
||||
// is a step of its own even when two of them land back to back.
|
||||
commit(wm.text !== customWm.text, () => {
|
||||
setCustomWm(wm);
|
||||
if (!wm.enabled) setCustomWmApplied({ ...DEFAULT_CUSTOM_WM });
|
||||
});
|
||||
};
|
||||
// Leaving the watermark editor = closing its panel. The mark keeps capturing
|
||||
// image touches ONLY while that panel is open (Viewfinder's wmArmed), so this
|
||||
@@ -171,14 +187,14 @@ export default function App() {
|
||||
// ACCEPT: the draft becomes the mark the export burns in, then the editor
|
||||
// closes so the image stops being a placement surface.
|
||||
const handleAcceptCustomWm = () => {
|
||||
setCustomWmApplied({ ...customWm });
|
||||
commit(false, () => setCustomWmApplied({ ...customWm }));
|
||||
closeWatermarkEditor();
|
||||
};
|
||||
// CANCEL: drop every un-accepted edit and fall back to the last ACCEPTED mark
|
||||
// (none at all when nothing was ever accepted), then close exactly like
|
||||
// ACCEPT — the escape hatch a running session previously lacked.
|
||||
const handleCancelCustomWm = () => {
|
||||
setCustomWm({ ...customWmApplied });
|
||||
commit(false, () => setCustomWm({ ...customWmApplied }));
|
||||
closeWatermarkEditor();
|
||||
};
|
||||
// Composition aspect-ratio (FULL / 4:3 / 3:2 upright) + RAW DNG sidecar.
|
||||
@@ -295,10 +311,17 @@ export default function App() {
|
||||
// vision-camera may return a raw absolute path on Android; surface a file:// URI.
|
||||
const toUri = (path: string) => (/^(file|content|ph):\/\//.test(path) ? path : `file://${path}`);
|
||||
|
||||
// Resolved by the mount effect just below, awaited by the share intake further
|
||||
// down: a share that cold-starts the app must land AFTER the session restore,
|
||||
// or the restored libraryImageUri/mode would replace the shared photo — and by
|
||||
// then the intent is consumed, so the photo is unreachable for good.
|
||||
const sessionInitRef = useRef<Promise<void> | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
// B5: run a single sequenced init — restore the session first, then fall
|
||||
// back to the first recipe only when no recipe was restored.
|
||||
initSession();
|
||||
// The restored look is step zero: BACK can never walk behind the load.
|
||||
sessionInitRef.current = initSession().then(clearHistory);
|
||||
// Do NOT auto-load device GPS here — it would override photo GPS on library mode
|
||||
}, []);
|
||||
|
||||
@@ -505,12 +528,14 @@ export default function App() {
|
||||
};
|
||||
|
||||
const applyRecipe = (recipe: Recipe) => {
|
||||
setSelectedRecipe(recipe);
|
||||
// EVERY knob the recipe carries lands in the panel, backfilled over the
|
||||
// defaults so a recipe saved before a knob existed cannot leave that knob on
|
||||
// whatever the previous look happened to have.
|
||||
setAdjustments({ ...DEFAULT_ADJUSTMENTS, ...recipe.adjustments });
|
||||
setSelectedFrame(recipe.frameId || 'none');
|
||||
commit(false, () => {
|
||||
setSelectedRecipe(recipe);
|
||||
// EVERY knob the recipe carries lands in the panel, backfilled over the
|
||||
// defaults so a recipe saved before a knob existed cannot leave that knob
|
||||
// on whatever the previous look happened to have.
|
||||
setAdjustments({ ...DEFAULT_ADJUSTMENTS, ...recipe.adjustments });
|
||||
setSelectedFrame(recipe.frameId || 'none');
|
||||
});
|
||||
// GPS watermark is the user's own setting (FRAME tab), not part of a
|
||||
// preset look: recipes carry a useGeotag field but selecting one must
|
||||
// never flip the watermark on/off.
|
||||
@@ -552,13 +577,21 @@ export default function App() {
|
||||
setCustomWm({ ...DEFAULT_CUSTOM_WM });
|
||||
setCustomWmApplied({ ...DEFAULT_CUSTOM_WM });
|
||||
setGpsWm({ ...DEFAULT_GPS_WM });
|
||||
// A look belongs to the photo and the mode it was edited on: the next load
|
||||
// starts a fresh history, even when the values come back the same.
|
||||
clearHistory();
|
||||
};
|
||||
|
||||
// RESET (panel chip, any tab): the state a default start would leave — the
|
||||
// startup look, every parameter back to neutral, frame off and both
|
||||
// watermarks back to their defaults. Same pieces as resetEditsToRecipe, but
|
||||
// the look goes back to the app's own default rather than the recipe's.
|
||||
// A RESET on an already-clean look is not a step: BACK would have nothing
|
||||
// to put back and would burn a press on a no-op.
|
||||
const handleResetAll = () => {
|
||||
if (resetDirty) commit(false, resetLookInPlace);
|
||||
};
|
||||
const resetLookInPlace = () => {
|
||||
const provia = FILM_SIMS.find((f) => f.id === 'sim-provia');
|
||||
if (provia) {
|
||||
applyRecipe(filmSimToRecipe(provia));
|
||||
@@ -603,29 +636,146 @@ export default function App() {
|
||||
cropRatio !== 'none' ||
|
||||
differs(cropRect, DEFAULT_CROP_RECT);
|
||||
|
||||
// ---- Hold-to-compare -----------------------------------------------------
|
||||
// While a LIGHT/WB/FX panel is open, pressing and holding on the picture
|
||||
// shows it with the last edit undone, so the user can flick between before
|
||||
// and after. The picture and the parameter display both follow the peek;
|
||||
// the stored values, RECIPE and the export never move.
|
||||
const PEEK_GAP_MS = 700; // a longer pause = a new gesture, so re-snapshot
|
||||
const peekRef = useRef<{ before: ColorAdjustments | null; at: number }>({
|
||||
before: null,
|
||||
at: 0,
|
||||
// ---- Look history: BACK (undo) + PEEK (hold to compare) ------------------
|
||||
// Every edit the panel can make is a step: PRESETS/RECIPES, LIGHT, WB, FX and
|
||||
// FRAME (frame, crop, turn, straighten, both watermarks, the GPS stamp). BACK
|
||||
// pops one step; PEEK — a hold on the picture — shows the
|
||||
// look one step back, with every tab's values travelling with it. Neither
|
||||
// touches the stored values, the RECIPE or the export: undo writes them, peek
|
||||
// only shows them. The history is seeded at every load, so BACK stops there.
|
||||
type Look = {
|
||||
recipe: Recipe | null;
|
||||
adjustments: ColorAdjustments;
|
||||
frame: FrameId;
|
||||
cropRatio: CropRatio;
|
||||
cropApplied: CropRatio | null;
|
||||
cropRect: CropRect;
|
||||
photoRotation: 0 | 90 | 180 | 270;
|
||||
photoStraighten: number;
|
||||
wallLandscape: boolean;
|
||||
useGeotag: boolean;
|
||||
wmRotation: number;
|
||||
customWm: CustomWatermark;
|
||||
customWmApplied: CustomWatermark;
|
||||
gpsWm: GpsWatermark;
|
||||
};
|
||||
const lookNow = (): Look => ({
|
||||
recipe: selectedRecipe,
|
||||
adjustments,
|
||||
frame: selectedFrame,
|
||||
cropRatio,
|
||||
cropApplied,
|
||||
cropRect,
|
||||
photoRotation,
|
||||
photoStraighten,
|
||||
wallLandscape,
|
||||
useGeotag,
|
||||
wmRotation,
|
||||
customWm,
|
||||
customWmApplied,
|
||||
gpsWm,
|
||||
});
|
||||
const [peekAdjustments, setPeekAdjustments] = useState<ColorAdjustments | null>(null);
|
||||
const peekArmed = openTab === 'iq' || openTab === 'wb' || openTab === 'filters';
|
||||
const handleComparePeek = useCallback((on: boolean) => {
|
||||
setPeekAdjustments(on ? peekRef.current.before : null);
|
||||
}, []);
|
||||
// The values the panel and the picture SHOW. While the finger is down they
|
||||
// fall back to the pre-edit snapshot, so the chip numbers and the open slider
|
||||
// travel back with the picture and the before/after is readable, not just
|
||||
// visible. The real values stay untouched: writes, RECIPE and the on-image
|
||||
// drag baseline all keep reading `adjustments`.
|
||||
const shownAdjustments = peekArmed && peekAdjustments ? peekAdjustments : adjustments;
|
||||
const withPeek = (r: Recipe): Recipe =>
|
||||
shownAdjustments === r.adjustments ? r : { ...r, adjustments: shownAdjustments };
|
||||
const lookRef = useRef<Look | null>(null);
|
||||
const pastRef = useRef<Look[]>([]);
|
||||
const [undoDepth, setUndoDepth] = useState(0);
|
||||
const [peekLook, setPeekLook] = useState<Look | null>(null);
|
||||
// A gesture is one step: a slider drag (or a run of keystrokes) inside this
|
||||
// gap stays the step it started as, so BACK undoes the whole gesture rather
|
||||
// than one event of it.
|
||||
const PEEK_GAP_MS = 700;
|
||||
const lastStepRef = useRef(0);
|
||||
// A step is pushed BEFORE the change lands. Nested commits (a handler whose
|
||||
// work calls another committing one, e.g. RESET -> applyRecipe) belong to the
|
||||
// outer step, or BACK would need one press per layer of one press.
|
||||
const commitDepthRef = useRef(0);
|
||||
const commit = (coalesce: boolean, mutate: () => void) => {
|
||||
if (commitDepthRef.current > 0) {
|
||||
mutate();
|
||||
return;
|
||||
}
|
||||
const now = Date.now();
|
||||
if (!coalesce || now - lastStepRef.current > PEEK_GAP_MS) {
|
||||
if (lookRef.current) pastRef.current.push(lookRef.current);
|
||||
setUndoDepth(pastRef.current.length);
|
||||
}
|
||||
lastStepRef.current = coalesce ? now : 0;
|
||||
commitDepthRef.current++;
|
||||
try {
|
||||
mutate();
|
||||
} finally {
|
||||
commitDepthRef.current--;
|
||||
}
|
||||
};
|
||||
// The loaded look is step zero — nothing before it can be undone.
|
||||
const clearHistory = () => {
|
||||
pastRef.current = [];
|
||||
lastStepRef.current = 0;
|
||||
setUndoDepth(0);
|
||||
};
|
||||
const applyLook = (l: Look) => {
|
||||
setSelectedRecipe(l.recipe);
|
||||
setAdjustments(l.adjustments);
|
||||
setSelectedFrame(l.frame);
|
||||
setCropRatio(l.cropRatio);
|
||||
setCropApplied(l.cropApplied);
|
||||
setCropRect(l.cropRect);
|
||||
setPhotoRotation(l.photoRotation);
|
||||
setPhotoStraighten(l.photoStraighten);
|
||||
setWallLandscape(l.wallLandscape);
|
||||
setUseGeotag(l.useGeotag);
|
||||
setWmRotation(l.wmRotation);
|
||||
setCustomWm(l.customWm);
|
||||
setCustomWmApplied(l.customWmApplied);
|
||||
setGpsWm(l.gpsWm);
|
||||
};
|
||||
const handleUndo = () => {
|
||||
if (!pro) {
|
||||
promptProFeature('Undo');
|
||||
return;
|
||||
}
|
||||
const prev = pastRef.current.pop();
|
||||
if (!prev) return;
|
||||
setPeekLook(null);
|
||||
lastStepRef.current = 0;
|
||||
setUndoDepth(pastRef.current.length);
|
||||
applyLook(prev);
|
||||
};
|
||||
const handlePeek = (on: boolean) => {
|
||||
if (!on) {
|
||||
setPeekLook(null);
|
||||
return;
|
||||
}
|
||||
const before = pastRef.current[pastRef.current.length - 1];
|
||||
setPeekLook(before ?? null);
|
||||
};
|
||||
// PEEK follows the finger on the picture (the chip is gone). Armed only when
|
||||
// there is a step to compare against, so a long press to focus cannot turn
|
||||
// into a paywall in a LITE build.
|
||||
const peekArmed = pro && undoDepth > 0;
|
||||
// The live look, mirrored every render so a handler can snapshot what was on
|
||||
// screen just before it changes it.
|
||||
const liveLook = lookNow();
|
||||
lookRef.current = liveLook;
|
||||
// The look the panel and the picture SHOW. While the finger is down on PEEK
|
||||
// (or on the picture) every number and every tab travels back a step, so the
|
||||
// before/after is readable, not just visible. The real values stay untouched:
|
||||
// writes, RECIPE and the on-image drag baseline all read the live ones.
|
||||
const shown = peekLook ?? liveLook;
|
||||
const shownAdjustments = shown.adjustments;
|
||||
const peeking = peekLook !== null;
|
||||
// What the preview draws: the shown recipe's own chrome (base filter, name)
|
||||
// rebuilt over the shown values, so a peek that steps over a PRESETS/RECIPES
|
||||
// change brings that look's filter back with it. A camera start carries no
|
||||
// recipe, so that stays the synthetic one.
|
||||
const previewRecipe: Recipe = {
|
||||
id: 'temp',
|
||||
name: 'Custom',
|
||||
baseFilter: 'none',
|
||||
...(shown.recipe ?? {}),
|
||||
adjustments: shown.adjustments,
|
||||
frameId: shown.frame,
|
||||
useGeotag: shown.useGeotag,
|
||||
};
|
||||
|
||||
const handleUpdateAdjustments = (updates: Partial<ColorAdjustments>) => {
|
||||
// Storm guard, generic across every param: same-value writes (slider
|
||||
@@ -635,27 +785,24 @@ export default function App() {
|
||||
// skipped identical EV needs no re-send.
|
||||
const keys = Object.keys(updates) as (keyof ColorAdjustments)[];
|
||||
if (keys.every((k) => updates[k] === adjustments[k])) return;
|
||||
// Remember where THIS edit started. A pause longer than PEEK_GAP_MS counts
|
||||
// as a new gesture, so the snapshot is retaken; within one drag the earlier
|
||||
// value survives and the peek shows the drag's own starting point.
|
||||
// ponytail: a >700ms stall in the middle of one drag re-snapshots mid-way.
|
||||
const peekNow = Date.now();
|
||||
if (peekNow - peekRef.current.at > PEEK_GAP_MS) {
|
||||
peekRef.current.before = adjustments;
|
||||
}
|
||||
peekRef.current.at = peekNow;
|
||||
// B8: never call a setState inside another setState's updater.
|
||||
const next = { ...adjustments, ...updates };
|
||||
setAdjustments(next);
|
||||
if (selectedRecipe) {
|
||||
setSelectedRecipe({
|
||||
...selectedRecipe,
|
||||
adjustments: next,
|
||||
});
|
||||
}
|
||||
// One drag = one step (see commit's coalescing): the step holds the values
|
||||
// the drag started from, so BACK and PEEK land on the drag's own start.
|
||||
// ponytail: a >700ms stall in the middle of one drag splits it in two.
|
||||
commit(true, () => {
|
||||
setAdjustments(next);
|
||||
if (selectedRecipe) {
|
||||
setSelectedRecipe({
|
||||
...selectedRecipe,
|
||||
adjustments: next,
|
||||
});
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const handleUpdateFrame = (frameId: FrameId) => {
|
||||
const handleUpdateFrame = (frameId: FrameId) => commit(false, () => applyFrame(frameId));
|
||||
const applyFrame = (frameId: FrameId) => {
|
||||
setSelectedFrame(frameId);
|
||||
// CROP is a plain-frame tool (polaroid/wall crop through their own window).
|
||||
// Keep the crop out of a frame that ignores it, or the ratio survives in the
|
||||
@@ -695,13 +842,15 @@ export default function App() {
|
||||
promptProFeature('The GPS watermark');
|
||||
return;
|
||||
}
|
||||
setUseGeotag(enabled);
|
||||
if (selectedRecipe) {
|
||||
setSelectedRecipe({
|
||||
...selectedRecipe,
|
||||
useGeotag: enabled,
|
||||
});
|
||||
}
|
||||
commit(false, () => {
|
||||
setUseGeotag(enabled);
|
||||
if (selectedRecipe) {
|
||||
setSelectedRecipe({
|
||||
...selectedRecipe,
|
||||
useGeotag: enabled,
|
||||
});
|
||||
}
|
||||
});
|
||||
if (enabled && !gpsInfo) {
|
||||
const locationGranted = await requestLocationPermissions();
|
||||
if (locationGranted) {
|
||||
@@ -968,7 +1117,8 @@ export default function App() {
|
||||
const handleAutoStraighten = useCallback(() => {
|
||||
const tilt = viewfinderRef.current?.detectHorizon?.() ?? null;
|
||||
if (tilt == null) return;
|
||||
setPhotoStraighten(-tilt);
|
||||
commit(false, () => setPhotoStraighten(-tilt));
|
||||
// commit only reads refs, so the empty dep list stays honest.
|
||||
}, []);
|
||||
|
||||
// OPEN IMAGE: the in-app picker (LibraryPickerModal) does the choosing — a
|
||||
@@ -1011,6 +1161,41 @@ export default function App() {
|
||||
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
|
||||
};
|
||||
|
||||
// System share intake. A shared photo arrives as the launch intent's
|
||||
// EXTRA_STREAM, which only the Kotlin module can read (takeSharedImage): it
|
||||
// copies the stream into the cache and this feeds it to the library pipeline
|
||||
// as an ordinary photo — same path as a pick, GPS and all.
|
||||
const sharedPickedRef = useRef(handleLibraryPicked);
|
||||
sharedPickedRef.current = handleLibraryPicked;
|
||||
useEffect(() => {
|
||||
let stale = false;
|
||||
let busy = false;
|
||||
const importShared = async () => {
|
||||
if (stale || busy) return;
|
||||
busy = true;
|
||||
try {
|
||||
await sessionInitRef.current;
|
||||
if (stale) return;
|
||||
const shared = await RecipescamExport.takeSharedImage();
|
||||
if (stale || !shared?.path) return;
|
||||
sharedPickedRef.current({ uri: `file://${shared.path}`, assetId: shared.assetId || null });
|
||||
} finally {
|
||||
busy = false;
|
||||
}
|
||||
};
|
||||
importShared();
|
||||
// The other half is a share landing on an app that is already alive: no
|
||||
// mount pass sees that intent, only this background -> active transition.
|
||||
// The native read clears the extra, so later resumes import nothing.
|
||||
const sub = AppState.addEventListener('change', (nextAppState) => {
|
||||
if (nextAppState === 'active') importShared();
|
||||
});
|
||||
return () => {
|
||||
stale = true;
|
||||
sub.remove();
|
||||
};
|
||||
}, []);
|
||||
|
||||
const activeRecipe: Recipe =
|
||||
selectedRecipe || {
|
||||
id: 'temp',
|
||||
@@ -1040,14 +1225,21 @@ export default function App() {
|
||||
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Warning);
|
||||
Alert.alert(
|
||||
'PRO LOOK',
|
||||
`${feature} can be previewed and edited for free, but exporting it needs the PRO build.`
|
||||
`${feature} can be previewed and edited, but exporting it needs PRO.`,
|
||||
[
|
||||
{ text: 'NOT NOW', style: 'cancel' },
|
||||
{ text: 'UNLOCK PRO', onPress: () => setShowPro(true) },
|
||||
]
|
||||
);
|
||||
};
|
||||
// Saving, starring, spawning: the action IS the feature, so there is no look
|
||||
// to preview first — a LITE tap names it and nothing else happens.
|
||||
const promptProFeature = (feature: string) => {
|
||||
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Warning);
|
||||
Alert.alert('PRO FEATURE', `${feature} needs the PRO build.`);
|
||||
Alert.alert('PRO FEATURE', `${feature} needs PRO.`, [
|
||||
{ text: 'NOT NOW', style: 'cancel' },
|
||||
{ text: 'UNLOCK PRO', onPress: () => setShowPro(true) },
|
||||
]);
|
||||
};
|
||||
|
||||
// Watermark handed to the export engines (Skia + native): the ACCEPTED mark
|
||||
@@ -1509,51 +1701,42 @@ export default function App() {
|
||||
ref={viewfinderRef}
|
||||
mode={mode}
|
||||
facing={cameraFacing}
|
||||
recipe={withPeek(
|
||||
selectedRecipe || {
|
||||
id: 'temp',
|
||||
name: 'Custom',
|
||||
baseFilter: 'none',
|
||||
adjustments,
|
||||
frameId: selectedFrame,
|
||||
useGeotag,
|
||||
}
|
||||
)}
|
||||
selectedFrame={selectedFrame}
|
||||
useGeotag={useGeotag}
|
||||
recipe={previewRecipe}
|
||||
selectedFrame={shown.frame}
|
||||
useGeotag={shown.useGeotag}
|
||||
gpsInfo={gpsInfo}
|
||||
libraryImageUri={libraryImageUri}
|
||||
availableHeight={deckTop}
|
||||
photoRotation={photoRotation}
|
||||
photoStraighten={photoStraighten}
|
||||
photoRotation={shown.photoRotation}
|
||||
photoStraighten={shown.photoStraighten}
|
||||
cameraPermissionGranted={cameraPermissionGranted}
|
||||
onRequestCameraPermission={requestCameraPermission}
|
||||
photoOutput={photoOutput}
|
||||
rawOutput={rawPhotoOutput}
|
||||
rawEnabled={rawEnabled}
|
||||
aspectRatio={aspectRatio}
|
||||
cropRatio={cropRatio}
|
||||
cropApplied={cropApplied}
|
||||
cropRect={cropRect}
|
||||
onCropRectChange={setCropRect}
|
||||
cropRatio={shown.cropRatio}
|
||||
cropApplied={shown.cropApplied}
|
||||
cropRect={shown.cropRect}
|
||||
onCropRectChange={(r) => commit(true, () => setCropRect(r))}
|
||||
onSessionError={handleCameraSessionError}
|
||||
onExposureChange={(ev) => handleUpdateAdjustments({ exposureCompensation: ev })}
|
||||
meteringAdjustments={meteringActive ? meteringOverlay(shownAdjustments) : undefined}
|
||||
imageAdjustTarget={imageAdjustTarget}
|
||||
onComparePeek={peekArmed ? handleComparePeek : undefined}
|
||||
onComparePeek={peekArmed ? handlePeek : undefined}
|
||||
// Touch-to-shutter: tap the live image -> focus locks -> capture.
|
||||
onTouchShutter={touchToShutter ? handleCapture : undefined}
|
||||
customWm={customWm}
|
||||
wmRotation={wmRotation}
|
||||
gpsWm={gpsWm}
|
||||
onGpsWmChange={(x, y, size) => setGpsWm((g) => ({ ...g, x, y, size }))}
|
||||
onWmSizeChange={(size) => setCustomWm((p) => ({ ...p, size }))}
|
||||
wallLandscape={wallLandscape}
|
||||
customWm={shown.customWm}
|
||||
wmRotation={shown.wmRotation}
|
||||
gpsWm={shown.gpsWm}
|
||||
onGpsWmChange={(x, y, size) => commit(true, () => setGpsWm((g) => ({ ...g, x, y, size })))}
|
||||
onWmSizeChange={(size) => commit(true, () => setCustomWm((p) => ({ ...p, size })))}
|
||||
wallLandscape={shown.wallLandscape}
|
||||
// The mark only captures image touches while its panel is open, so a
|
||||
// watermark left ON cannot block tap-to-focus or pinch/zoom.
|
||||
wmEditing={openTab === 'frame' && wmPanelOpen}
|
||||
frameTabActive={openTab === 'frame'}
|
||||
onWmPositionChange={(x, y) => setCustomWm((p) => ({ ...p, x, y }))}
|
||||
onWmPositionChange={(x, y) => commit(true, () => setCustomWm((p) => ({ ...p, x, y })))}
|
||||
onWmEdit={() => {
|
||||
// Double-tap on the mark re-opens its editor (and with it the
|
||||
// placement layer), then focuses the input.
|
||||
@@ -1567,13 +1750,15 @@ export default function App() {
|
||||
{/* Top overlay: mode label + recipe name (left), Save current (right) */}
|
||||
<TopBar
|
||||
mode={mode}
|
||||
recipeName={activeRecipe.name}
|
||||
recipeName={peeking ? previewRecipe.name : activeRecipe.name}
|
||||
onSaveRecipe={handleSaveRecipe}
|
||||
onOverwriteRecipe={selectedRecipe?.isCustom ? handleOverwriteRecipe : undefined}
|
||||
onEditRecipe={
|
||||
selectedRecipe?.isCustom ? () => setEditingRecipe(selectedRecipe) : undefined
|
||||
}
|
||||
onExportShare={handleExportShare}
|
||||
onUndo={handleUndo}
|
||||
undoDepth={undoDepth}
|
||||
/>
|
||||
|
||||
{/* Bottom deck: chip panel (progressive disclosure) -> tool rail -> shutter */}
|
||||
@@ -1598,25 +1783,26 @@ export default function App() {
|
||||
adjustments={shownAdjustments}
|
||||
onResetAll={handleResetAll}
|
||||
resetDirty={resetDirty}
|
||||
selectedFrame={selectedFrame}
|
||||
cropRatio={cropRatio}
|
||||
onUpdateCropRatio={setCropRatio}
|
||||
cropApplied={cropApplied}
|
||||
onApplyCrop={() => setCropApplied(cropRatio)}
|
||||
selectedFrame={shown.frame}
|
||||
cropRatio={shown.cropRatio}
|
||||
onUpdateCropRatio={(r) => commit(false, () => setCropRatio(r))}
|
||||
cropApplied={shown.cropApplied}
|
||||
onApplyCrop={() => commit(false, () => setCropApplied(cropRatio))}
|
||||
cropEnabled={
|
||||
mode === 'library' &&
|
||||
(selectedFrame === 'none' ||
|
||||
selectedFrame === 'classic-white' ||
|
||||
selectedFrame === 'cinematic')
|
||||
}
|
||||
photoRotation={photoRotation}
|
||||
photoStraighten={photoStraighten}
|
||||
onPickRotation={setPhotoRotation}
|
||||
onStraighten={setPhotoStraighten}
|
||||
photoRotation={shown.photoRotation}
|
||||
photoStraighten={shown.photoStraighten}
|
||||
onPickRotation={(d) => commit(false, () => setPhotoRotation(d))}
|
||||
onStraighten={(d) => commit(true, () => setPhotoStraighten(d))}
|
||||
onAutoStraighten={handleAutoStraighten}
|
||||
wallLandscape={wallLandscape}
|
||||
onToggleWallLandscape={() => setWallLandscape((v) => !v)}
|
||||
useGeotag={useGeotag}
|
||||
wallLandscape={shown.wallLandscape}
|
||||
onToggleWallLandscape={() => commit(false, () => setWallLandscape((v) => !v))}
|
||||
useGeotag={shown.useGeotag}
|
||||
pro={pro}
|
||||
openParam={openParamKey}
|
||||
onOpenParamChange={setOpenParamKey}
|
||||
currentBaseFilter={selectedRecipe?.baseFilter}
|
||||
@@ -1635,15 +1821,15 @@ export default function App() {
|
||||
recipeLimitReached={liteSaveBlocked}
|
||||
onRecipeLimit={promptRecipeLimit}
|
||||
onDeleteRecipe={handleDeleteRecipe}
|
||||
customWm={customWm}
|
||||
customWm={shown.customWm}
|
||||
onUpdateCustomWm={handleUpdateCustomWm}
|
||||
gpsWm={gpsWm}
|
||||
onUpdateGpsWm={setGpsWm}
|
||||
gpsWm={shown.gpsWm}
|
||||
onUpdateGpsWm={(wm) => commit(true, () => setGpsWm(wm))}
|
||||
onAcceptCustomWm={handleAcceptCustomWm}
|
||||
onCancelCustomWm={handleCancelCustomWm}
|
||||
wmEditNonce={wmEditNonce}
|
||||
wmRotation={wmRotation}
|
||||
onRotateWm={() => setWmRotation((r) => (r + 90) % 360)}
|
||||
wmRotation={shown.wmRotation}
|
||||
onRotateWm={() => commit(false, () => setWmRotation((r) => (r + 90) % 360))}
|
||||
onWmPanelChange={setWmPanelOpen}
|
||||
/>
|
||||
</View>
|
||||
@@ -1692,8 +1878,12 @@ export default function App() {
|
||||
startupMode={startupMode}
|
||||
onStartupMode={handleStartupMode}
|
||||
pro={pro}
|
||||
onUpgrade={() => setShowPro(true)}
|
||||
/>
|
||||
|
||||
{/* The paywall: the only place the tier is sold. */}
|
||||
<ProModal visible={showPro} onClose={() => setShowPro(false)} />
|
||||
|
||||
{/* Session photo viewer (swipe across captured/exported photos) */}
|
||||
<PhotoViewerModal
|
||||
visible={viewerIndex !== null && photoHistory.length > 0}
|
||||
|
||||
@@ -22,6 +22,7 @@ Generated 2026-09-09 from node_modules audit, rev. 2026-09-14 — 31 shipped com
|
||||
| expo-file-system | 57.0.1 | MIT | Copyright (c) 2015-present 650 Industries, Inc. (aka Expo) | https://docs.expo.dev/versions/latest/sdk/filesystem/ |
|
||||
| expo-font | 57.0.1 | MIT | Copyright (c) 2015-present 650 Industries, Inc. (aka Expo) | https://docs.expo.dev/versions/latest/sdk/font/ |
|
||||
| expo-haptics | 57.0.1 | MIT | Copyright (c) 2015-present 650 Industries, Inc. (aka Expo) | https://docs.expo.dev/versions/latest/sdk/haptics/ |
|
||||
| expo-iap | 5.6.0 | MIT | Copyright (c) 2025 hyochan | https://github.com/hyodotdev/openiap/tree/main/libraries/expo-iap#readme |
|
||||
| expo-keep-awake | 57.0.1 | MIT | Copyright (c) 2015-present 650 Industries, Inc. (aka Expo) | https://docs.expo.dev/versions/latest/sdk/keep-awake/ |
|
||||
| expo-location | 57.0.4 | MIT | Copyright (c) 2015-present 650 Industries, Inc. (aka Expo) | https://docs.expo.dev/versions/latest/sdk/location/ |
|
||||
| expo-media-library | 57.0.2 | MIT | Copyright (c) 2015-present 650 Industries, Inc. (aka Expo) | https://docs.expo.dev/versions/latest/sdk/media-library/ |
|
||||
@@ -130,7 +131,7 @@ SOFTWARE.
|
||||
|
||||
## MIT
|
||||
|
||||
Applied to: @react-native-async-storage/async-storage (Copyright (c) 2015-present, Facebook, Inc.), @react-native-community/slider (react-native-community), @shopify/react-native-skia (Copyright 2021-present, Shopify Inc.), expo (Copyright (c) 2015-present 650 Industries, Inc. (aka Expo)), expo-asset (Copyright (c) 2015-present 650 Industries, Inc. (aka Expo)), expo-dev-client (Copyright (c) 2015-present 650 Industries, Inc. (aka Expo)), expo-document-picker (Copyright (c) 2015-present 650 Industries, Inc. (aka Expo)), expo-file-system (Copyright (c) 2015-present 650 Industries, Inc. (aka Expo)), expo-font (Copyright (c) 2015-present 650 Industries, Inc. (aka Expo)), expo-haptics (Copyright (c) 2015-present 650 Industries, Inc. (aka Expo)), expo-keep-awake (Copyright (c) 2015-present 650 Industries, Inc. (aka Expo)), expo-location (Copyright (c) 2015-present 650 Industries, Inc. (aka Expo)), expo-media-library (Copyright (c) 2015-present 650 Industries, Inc. (aka Expo)), expo-sharing (Copyright (c) 2015-present 650 Industries, Inc. (aka Expo)), expo-status-bar (Copyright (c) 2015-present 650 Industries, Inc. (aka Expo)), nativewind (Copyright (c) 2023 Mark Lawlor), react (Copyright (c) Meta Platforms, Inc. and affiliates.), react-dom (Copyright (c) Meta Platforms, Inc. and affiliates.), react-native (Copyright (c) Meta Platforms, Inc. and affiliates.), react-native-nitro-image (Marc Rousavy <me@mrousavy.com> (https://github.com/mrousavy)), react-native-nitro-modules (Marc Rousavy <me@mrousavy.com> (https://github.com/mrousavy)), react-native-reanimated (Copyright (c) 2016 Software Mansion <swmansion.com>), react-native-safe-area-context (Copyright (c) 2019 Th3rd Wave), react-native-vision-camera (Marc Rousavy <me@mrousavy.com> (https://github.com/mrousavy)), react-native-vision-camera-skia (Marc Rousavy <me@mrousavy.com> (https://github.com/mrousavy)), react-native-vision-camera-worklets (Marc Rousavy <me@mrousavy.com> (https://github.com/mrousavy)), react-native-web (Copyright (c) Nicolas Gallagher.)
|
||||
Applied to: @react-native-async-storage/async-storage (Copyright (c) 2015-present, Facebook, Inc.), @react-native-community/slider (react-native-community), @shopify/react-native-skia (Copyright 2021-present, Shopify Inc.), expo (Copyright (c) 2015-present 650 Industries, Inc. (aka Expo)), expo-asset (Copyright (c) 2015-present 650 Industries, Inc. (aka Expo)), expo-dev-client (Copyright (c) 2015-present 650 Industries, Inc. (aka Expo)), expo-document-picker (Copyright (c) 2015-present 650 Industries, Inc. (aka Expo)), expo-file-system (Copyright (c) 2015-present 650 Industries, Inc. (aka Expo)), expo-font (Copyright (c) 2015-present 650 Industries, Inc. (aka Expo)), expo-haptics (Copyright (c) 2015-present 650 Industries, Inc. (aka Expo)), expo-iap (Copyright (c) 2025 hyochan), expo-keep-awake (Copyright (c) 2015-present 650 Industries, Inc. (aka Expo)), expo-location (Copyright (c) 2015-present 650 Industries, Inc. (aka Expo)), expo-media-library (Copyright (c) 2015-present 650 Industries, Inc. (aka Expo)), expo-sharing (Copyright (c) 2015-present 650 Industries, Inc. (aka Expo)), expo-status-bar (Copyright (c) 2015-present 650 Industries, Inc. (aka Expo)), nativewind (Copyright (c) 2023 Mark Lawlor), react (Copyright (c) Meta Platforms, Inc. and affiliates.), react-dom (Copyright (c) Meta Platforms, Inc. and affiliates.), react-native (Copyright (c) Meta Platforms, Inc. and affiliates.), react-native-nitro-image (Marc Rousavy <me@mrousavy.com> (https://github.com/mrousavy)), react-native-nitro-modules (Marc Rousavy <me@mrousavy.com> (https://github.com/mrousavy)), react-native-reanimated (Copyright (c) 2016 Software Mansion <swmansion.com>), react-native-safe-area-context (Copyright (c) 2019 Th3rd Wave), react-native-vision-camera (Marc Rousavy <me@mrousavy.com> (https://github.com/mrousavy)), react-native-vision-camera-skia (Marc Rousavy <me@mrousavy.com> (https://github.com/mrousavy)), react-native-vision-camera-worklets (Marc Rousavy <me@mrousavy.com> (https://github.com/mrousavy)), react-native-web (Copyright (c) Nicolas Gallagher.)
|
||||
|
||||
```text
|
||||
MIT License
|
||||
|
||||
@@ -27,6 +27,17 @@
|
||||
<action android:name="android.intent.action.MAIN"/>
|
||||
<category android:name="android.intent.category.LAUNCHER"/>
|
||||
</intent-filter>
|
||||
<!-- Share target. Without this filter the app never appears in the
|
||||
system share sheet at all; image/* only, the editor takes photos.
|
||||
Goes on the launcher activity: it is singleTask, so a share
|
||||
re-delivered there replaces its intent (onNewIntent -> setIntent)
|
||||
and the running app picks the photo up without a cold start. -->
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.SEND"/>
|
||||
<action android:name="android.intent.action.SEND_MULTIPLE"/>
|
||||
<category android:name="android.intent.category.DEFAULT"/>
|
||||
<data android:mimeType="image/*"/>
|
||||
</intent-filter>
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.VIEW"/>
|
||||
<category android:name="android.intent.category.DEFAULT"/>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.locphamtran.recipescamera
|
||||
|
||||
import android.content.Intent
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
|
||||
@@ -19,6 +20,17 @@ class MainActivity : ReactActivity() {
|
||||
super.onCreate(null)
|
||||
}
|
||||
|
||||
/**
|
||||
* A share delivered to the running app lands here, and React Native never hands
|
||||
* the new intent to the activity (nothing in ReactActivity/ReactDelegate calls
|
||||
* setIntent) — so getIntent() keeps returning the launch intent and the share
|
||||
* intake would read a photo the user shared minutes ago, or none at all.
|
||||
*/
|
||||
override fun onNewIntent(intent: Intent) {
|
||||
setIntent(intent)
|
||||
super.onNewIntent(intent)
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the name of the main component registered from JavaScript. This is used to schedule
|
||||
* rendering of the component.
|
||||
|
||||
+105
-7
@@ -2,6 +2,7 @@ package com.locphamtran.recipescamera.export
|
||||
|
||||
import android.content.ContentValues
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.BitmapFactory
|
||||
import android.graphics.Canvas
|
||||
@@ -11,6 +12,7 @@ import android.graphics.Paint
|
||||
import android.graphics.Rect
|
||||
import android.graphics.RectF
|
||||
import android.graphics.Typeface
|
||||
import android.net.Uri
|
||||
import android.os.Environment
|
||||
import android.provider.MediaStore
|
||||
import android.util.Base64
|
||||
@@ -77,6 +79,23 @@ class RecipescamExportModule : Module() {
|
||||
Name("RecipesCamExport")
|
||||
Log.i(TAG, "module defined RecipesCamExport")
|
||||
|
||||
// System share sheet intake. AndroidManifest declares this app a share
|
||||
// target (ACTION_SEND / ACTION_SEND_MULTIPLE, image/*), and the shared photo
|
||||
// exists only as the intent's EXTRA_STREAM — a content:// Uri that no JS API
|
||||
// here can read (expo-linking surfaces VIEW data, never stream extras, and on
|
||||
// 13+ the sender's uri is short-lived and redacted). So the stream is copied
|
||||
// natively into the cache and handed to JS as an ordinary file that the normal
|
||||
// library pipeline (path + assetId -> EXIF/MediaStore GPS) reads.
|
||||
AsyncFunction("takeSharedImage") Coroutine { ->
|
||||
// Read live, never cached: launchMode=singleTask re-delivers a share that
|
||||
// arrives while we are alive through onNewIntent -> setIntent, so the
|
||||
// activity's intent is always the current one and no native hook is needed.
|
||||
val ctx = appContext.reactContext
|
||||
val activity = appContext.currentActivity
|
||||
if (ctx == null || activity == null) emptyMap<String, String>()
|
||||
else withContext(Dispatchers.IO) { consumeSharedImage(ctx, activity.intent) }
|
||||
}
|
||||
|
||||
// Save an encoded JPEG/PNG into DCIM/Camera. expo-media-library's JS API
|
||||
// only ever inserts into DCIM (root) or Pictures/Camera, so a camera-roll
|
||||
// export lands where the stock camera puts its shots by inserting the
|
||||
@@ -337,6 +356,9 @@ private suspend fun processPipeline(
|
||||
val aspect = adjust["aspect"] as? String
|
||||
val dpi = (adjust["dpi"] as? Number)?.toInt() ?: 0
|
||||
val sharpen = (adjust["sharpen"] as? Number)?.toDouble() ?: 0.0
|
||||
// Negative SHARPENING knob (engine step 4): pre-grain gaussian soften, must
|
||||
// run before the grain pass or the blur eats the re-grained noise.
|
||||
val soften = (adjust["soften"] as? Number)?.toDouble() ?: 0.0
|
||||
require(matrixList.size == 20) { "color matrix needs 20 floats, got ${matrixList.size}" }
|
||||
val matrix = FloatArray(20) { i -> (matrixList[i] as? Number)?.toFloat() ?: 0f }
|
||||
val cinemaFlat = FloatArray(16) { i -> (cinemaList?.getOrNull(i) as? Number)?.toFloat() ?: 0f }
|
||||
@@ -434,11 +456,13 @@ private suspend fun processPipeline(
|
||||
Log.i(TAG, "centerPixel rgb=(${android.graphics.Color.red(center)},${android.graphics.Color.green(center)},${android.graphics.Color.blue(center)})")
|
||||
bmp.recycle()
|
||||
|
||||
// 5. Denoise / clarity / grain (exportEngine #4 + #6), CPU passes on the
|
||||
// matrix output. Engine chain: denoise blur -> clarity conv (or mist blur),
|
||||
// then a grain Overlay rect on top.
|
||||
val needEnhance = denoise > 0 || clarity != 0.0
|
||||
val needGrain = grain > 0
|
||||
// 5. Denoise / clarity / soften / grain (exportEngine #4 + #6), CPU passes
|
||||
// on the matrix output. Engine chain: denoise blur -> clarity conv (or mist
|
||||
// blur) -> soften blur for a negative SHARPENING knob, then a grain Overlay
|
||||
// rect on top. A negative NOISE REDUCTION knob adds re-grain instead of
|
||||
// blurring, so a -10/-10 photo comes out soft AND grainy.
|
||||
val needEnhance = denoise > 0 || clarity != 0.0 || soften > 0
|
||||
val needGrain = grain > 0 || denoise < 0
|
||||
if (needEnhance || needGrain) {
|
||||
val t6 = System.nanoTime()
|
||||
val wO = out.width
|
||||
@@ -456,10 +480,16 @@ private suspend fun processPipeline(
|
||||
val mistSigma = (abs(clarity) / 10.0) * 4.0 // bloom/mist
|
||||
p = boxBlur(p, wO, hO, max(1, mistSigma.roundToInt()))
|
||||
}
|
||||
// Negative SHARPENING: same 3px-max gaussian the engine puts in the
|
||||
// filter chain before grain (MakeBlur sigma = |knob|/10*3).
|
||||
if (soften > 0) p = boxBlur(p, wO, hO, max(1, soften.roundToInt()))
|
||||
if (needEnhance) timings["clarityMs"] = ((System.nanoTime() - t6) / 1_000_000).toInt()
|
||||
val t7 = System.nanoTime()
|
||||
if (grain > 0) {
|
||||
val opacity = (grain / 20.0).toFloat().coerceIn(0f, 1f) // engine: grain/20
|
||||
// Engine #6 grain amount: positive grain plus the re-grain a negative
|
||||
// NOISE REDUCTION knob asks for, clamped to the 0..10 knob range.
|
||||
val g = min(10.0, max(0.0, grain) + max(0.0, -denoise))
|
||||
if (g > 0) {
|
||||
val opacity = (g / 20.0).toFloat().coerceIn(0f, 1f) // engine: grain/20
|
||||
p = grainOverlay(p, wO, hO, opacity)
|
||||
timings["grainMs"] = ((System.nanoTime() - t7) / 1_000_000).toInt()
|
||||
Log.i(TAG, "grain applied opacity=$opacity")
|
||||
@@ -1216,3 +1246,71 @@ private fun parityCompare(ctx: Context, pathA: String, pathB: String, outPath: S
|
||||
"gt32pct" to (gt[2] * 100.0 / total),
|
||||
)
|
||||
}
|
||||
|
||||
// ---- system share intake (AndroidManifest ACTION_SEND) ---------------------
|
||||
|
||||
/**
|
||||
* The photo a share handed this launch, copied out of the sender's content://
|
||||
* stream into our own cache and returned as { path, assetId, type } — all empty
|
||||
* when this launch was not a share, or when the share was already taken.
|
||||
*
|
||||
* The copy is what lets the rest of the app work unchanged: every other entry
|
||||
* point (LibraryPickerModal, the export pipeline, the EXIF read) wants a real
|
||||
* file, and the sender's read grant only ever covers this one activity.
|
||||
*
|
||||
* ponytail: only the FIRST stream is read, so ACTION_SEND_MULTIPLE's 2nd..nth
|
||||
* images are dropped — the editor works on one photo at a time; add when a
|
||||
* multi-photo queue exists.
|
||||
*/
|
||||
private fun consumeSharedImage(ctx: Context, intent: Intent?): Map<String, String> {
|
||||
if (intent == null) return emptyMap()
|
||||
val action = intent.action
|
||||
if (action != Intent.ACTION_SEND && action != Intent.ACTION_SEND_MULTIPLE) return emptyMap()
|
||||
val uri = sharedStreamUri(intent) ?: return emptyMap()
|
||||
// Consumed: the activity keeps this intent for its whole life and JS re-asks
|
||||
// on every foreground return, so the extra goes — otherwise every resume
|
||||
// re-imported the same photo and threw the edits already made on it away.
|
||||
intent.removeExtra(Intent.EXTRA_STREAM)
|
||||
intent.clipData = null
|
||||
// The sender's declared type, not the file name: several apps share HEIC or
|
||||
// PNG streams with no usable name at all.
|
||||
val type = ctx.contentResolver.getType(uri) ?: intent.type ?: "image/*"
|
||||
val ext = when {
|
||||
type.contains("png") -> "png"
|
||||
type.contains("webp") -> "webp"
|
||||
type.contains("heic") || type.contains("heif") -> "heic"
|
||||
else -> "jpg"
|
||||
}
|
||||
val dst = File(File(ctx.cacheDir, "shared").apply { mkdirs() }, "shared-${System.currentTimeMillis()}.$ext")
|
||||
try {
|
||||
ctx.contentResolver.openInputStream(uri).use { input ->
|
||||
requireNotNull(input) { "unreadable share stream" }
|
||||
dst.outputStream().use { input.copyTo(it) }
|
||||
}
|
||||
} catch (e: Throwable) {
|
||||
Log.w(TAG, "shared image copy failed: ${e.message}")
|
||||
dst.delete()
|
||||
return emptyMap()
|
||||
}
|
||||
// MediaStore ids are the trailing number of the uri — "content://media/
|
||||
// external/images/media/12345", or the document form ".../image%3A12345"
|
||||
// (percent-decoded by Uri itself). The library pipeline uses it for the
|
||||
// MediaStore location lookup when the file carries no EXIF GPS.
|
||||
val id = (uri.lastPathSegment ?: "").substringAfterLast(':')
|
||||
val assetId = if (id.isNotEmpty() && id.all { c -> c.isDigit() }) id else ""
|
||||
Log.i(TAG, "shared image -> ${dst.absolutePath} (type=$type, assetId=$assetId)")
|
||||
return mapOf("path" to dst.absolutePath, "assetId" to assetId, "type" to type)
|
||||
}
|
||||
|
||||
/**
|
||||
* The sender's stream. Every API level puts it in EXTRA_STREAM — one Uri for
|
||||
* SEND, a ParcelableArrayList for SEND_MULTIPLE — and a few senders fill in
|
||||
* only clipData, so all three shapes are accepted.
|
||||
*/
|
||||
@Suppress("DEPRECATION")
|
||||
private fun sharedStreamUri(intent: Intent): Uri? {
|
||||
intent.getParcelableExtra<Uri>(Intent.EXTRA_STREAM)?.let { return it }
|
||||
intent.getParcelableArrayListExtra<Uri>(Intent.EXTRA_STREAM)?.firstOrNull()?.let { return it }
|
||||
val clip = intent.clipData
|
||||
return if (clip != null && clip.itemCount > 0) clip.getItemAt(0).uri else null
|
||||
}
|
||||
|
||||
@@ -42,7 +42,7 @@ export interface PhotoAdjust {
|
||||
toneSh?: number;
|
||||
/** 16 flat floats from cinemaShader.ts getCinemaUniforms(season).flat; null/omitted or flat[0]=0 = skip. */
|
||||
cinema?: number[] | null;
|
||||
/** ColorAdjustments.denoise 0..10 (0 = skip). */
|
||||
/** ColorAdjustments.denoise -10..10; >0 blur, <0 re-grain (0 = skip). */
|
||||
denoise?: number;
|
||||
/** ColorAdjustments.clarity -10..10; >0 conv sharpen, <0 mist blur (0 = skip). */
|
||||
clarity?: number;
|
||||
@@ -52,6 +52,8 @@ export interface PhotoAdjust {
|
||||
quality?: number;
|
||||
/** Final screen-sharpen amount (exportEngine #9 screenSharpenImage); >0 applies. */
|
||||
sharpen?: number;
|
||||
/** Soften blur radius for a negative sharpening knob (exportEngine #4); >0 applies. */
|
||||
soften?: number;
|
||||
/** DPI stamped into the JPEG header after encode (patchJpegDpi port); 0 = skip. */
|
||||
dpi?: number;
|
||||
/** Composition aspect-ratio crop computed after decode (center-largest, engine #1b). */
|
||||
@@ -104,6 +106,15 @@ export interface RecipescamExportModule {
|
||||
*/
|
||||
deviceInfo(): { device: string; marketName: string; manufacturer: string; model: string };
|
||||
|
||||
/**
|
||||
* The image a system share handed this launch (AndroidManifest
|
||||
* ACTION_SEND — see takeSharedImage in the Kotlin module): the sender's
|
||||
* stream is copied into the cache and returned as an ordinary file.
|
||||
* `path` is "" when this launch was not a share, or when the share was
|
||||
* already taken — the intent is read and cleared exactly once.
|
||||
*/
|
||||
takeSharedImage(): Promise<{ path: string; assetId: string; type: string }>;
|
||||
|
||||
/** P0 probe helpers — removed before ship. */
|
||||
ping(): Promise<string>;
|
||||
materializeBenchAsset(assetName: string): Promise<string>;
|
||||
|
||||
Generated
+24
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "recipescamera",
|
||||
"version": "1.2",
|
||||
"version": "1.2.1",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "recipescamera",
|
||||
"version": "1.2",
|
||||
"version": "1.2.1",
|
||||
"dependencies": {
|
||||
"@react-native-async-storage/async-storage": "2.2.0",
|
||||
"@react-native-community/slider": "5.2.0",
|
||||
@@ -19,6 +19,7 @@
|
||||
"expo-file-system": "~57.0.1",
|
||||
"expo-font": "~57.0.1",
|
||||
"expo-haptics": "~57.0.1",
|
||||
"expo-iap": "^5.6.0",
|
||||
"expo-keep-awake": "~57.0.1",
|
||||
"expo-location": "~57.0.4",
|
||||
"expo-media-library": "~57.0.2",
|
||||
@@ -3487,6 +3488,27 @@
|
||||
"expo": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/expo-iap": {
|
||||
"version": "5.6.0",
|
||||
"resolved": "https://registry.npmjs.org/expo-iap/-/expo-iap-5.6.0.tgz",
|
||||
"integrity": "sha512-Rqz8wrxAMuOTNpdOxSpizFRQmNQIQ+cpHY/120rT8HY8XZmlp7EiCnNT9Ybh61W4XIpGVSwNLm4ETH01DfmRCA==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@amazon-devices/keplerscript-appstore-iap-lib": "~2.13.0",
|
||||
"@amazon-devices/package-manager-lib": "~1.0.1767254401",
|
||||
"expo": "*",
|
||||
"react": "*",
|
||||
"react-native": "*"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@amazon-devices/keplerscript-appstore-iap-lib": {
|
||||
"optional": true
|
||||
},
|
||||
"@amazon-devices/package-manager-lib": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/expo-json-utils": {
|
||||
"version": "57.0.1",
|
||||
"resolved": "https://registry.npmjs.org/expo-json-utils/-/expo-json-utils-57.0.1.tgz",
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
"expo-file-system": "~57.0.1",
|
||||
"expo-font": "~57.0.1",
|
||||
"expo-haptics": "~57.0.1",
|
||||
"expo-iap": "^5.6.0",
|
||||
"expo-keep-awake": "~57.0.1",
|
||||
"expo-location": "~57.0.4",
|
||||
"expo-media-library": "~57.0.2",
|
||||
|
||||
@@ -6,7 +6,7 @@ import { Star, Trash2 } from 'lucide-react-native';
|
||||
import { Skia } from '@shopify/react-native-skia';
|
||||
import { Recipe, ColorAdjustments, CustomWatermark, GpsWatermark, FrameId, BaseFilter, CropRatio } from '../types';
|
||||
import { FRAMES } from '../utils/frameUtils';
|
||||
import { IS_PRO, PRO_FRAMES } from '../utils/entitlement';
|
||||
import { PRO_FRAMES } from '../utils/entitlement';
|
||||
import { PARAM_DEFS, ContinuousParamDef } from '../utils/paramDefs';
|
||||
import { FILM_SIMS, filmSimToRecipe } from '../utils/defaultRecipes';
|
||||
import RecipeCreateModal from './RecipeCreateModal';
|
||||
@@ -66,6 +66,10 @@ interface AdjustmentPanelProps {
|
||||
onApplyCrop: () => void;
|
||||
cropEnabled?: boolean;
|
||||
useGeotag: boolean;
|
||||
// Runtime tier: PRO apk or a LITE apk that bought the unlock. A LITE build
|
||||
// keeps every PRO chip on screen, greyed with its corner tag, so the panel
|
||||
// has to read the value at render time rather than at build time.
|
||||
pro: boolean;
|
||||
// RAW (DNG sidecar) / aspect-ratio / metering-mode now live in the gear
|
||||
// Settings sheet (gear between library picker and shutter) — no longer
|
||||
// tool-rail tabs.
|
||||
@@ -141,6 +145,9 @@ interface AdjustmentPanelProps {
|
||||
// FRAME → WATERMARK sub-panel is open: App needs this to arm the mark's
|
||||
// drag/pinch layer on the image (the old `activeTab === 'watermark'`).
|
||||
onWmPanelChange: (open: boolean) => void;
|
||||
// BACK (undo) and PEEK (hold to compare) both work on the whole LOOK, not on
|
||||
// one parameter, and both left this panel: BACK is a button in the top bar,
|
||||
// PEEK is a hold on the picture itself (TopBar, Viewfinder).
|
||||
}
|
||||
|
||||
interface ChipDef {
|
||||
@@ -229,6 +236,7 @@ export default function AdjustmentPanel({
|
||||
onApplyCrop,
|
||||
cropEnabled,
|
||||
useGeotag,
|
||||
pro,
|
||||
openParam,
|
||||
onOpenParamChange,
|
||||
currentBaseFilter,
|
||||
@@ -338,9 +346,8 @@ export default function AdjustmentPanel({
|
||||
? 'border-amber-500/70 bg-amber-500/15'
|
||||
: 'border-zinc-700/60 bg-titan/80';
|
||||
const textTone = c.active || c.amberValue ? 'text-amber-500' : 'text-zinc-300';
|
||||
// LITE only: corner tag on every chip whose look LITE cannot export. PRO
|
||||
// never sees it (there the condition is a constant false).
|
||||
const proTag = c.pro && !IS_PRO;
|
||||
// LITE only: corner tag on every chip whose look LITE cannot export.
|
||||
const proTag = c.pro && !pro;
|
||||
return (
|
||||
<View
|
||||
key={c.key}
|
||||
@@ -424,7 +431,7 @@ export default function AdjustmentPanel({
|
||||
// A PRO-only parameter stays visible in LITE as a greyed chip with its
|
||||
// corner PRO tag — named where it is, which is the only place it can be.
|
||||
pro: p.pro,
|
||||
disabled: !!p.pro && !IS_PRO,
|
||||
disabled: !!p.pro && !pro,
|
||||
onPress: () => toggleParam(p.key),
|
||||
}));
|
||||
|
||||
@@ -560,7 +567,7 @@ export default function AdjustmentPanel({
|
||||
const wmFontOptions = useMemo(() => {
|
||||
// LITE ships a single basic face: the platform's own families (what
|
||||
// DEFAULT is not) are the paid half of the FONT strip.
|
||||
if (!IS_PRO) return [{ v: 'DEFAULT', d: 'DEFAULT' }];
|
||||
if (!pro) return [{ v: 'DEFAULT', d: 'DEFAULT' }];
|
||||
const mgr = Skia.FontMgr.System();
|
||||
const names = new Set<string>();
|
||||
for (let i = 0; i < mgr.countFamilies() && names.size < 120; i++) {
|
||||
@@ -571,7 +578,7 @@ export default function AdjustmentPanel({
|
||||
{ v: 'DEFAULT', d: 'DEFAULT' },
|
||||
...[...names].sort().map((n) => ({ v: n, d: n })),
|
||||
];
|
||||
}, []);
|
||||
}, [pro]);
|
||||
const groupDefs: Record<Exclude<GroupKey, 'wm'>, { label: string; off: string; value: string; options: { v: string; d: string }[]; onPick: (v: string) => void }> = {
|
||||
cx: {
|
||||
label: 'COLOR CHROME', off: 'none', value: a.colorChrome || 'none', options: chromeOptions,
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
import React from 'react';
|
||||
import { View, Text, Modal, ScrollView, TouchableOpacity } from 'react-native';
|
||||
import { X, Zap, RefreshCw } from 'lucide-react-native';
|
||||
|
||||
import { buyPro, restorePro, useProUnlock } from '../utils/proUnlock';
|
||||
import { PRO_FRAMES } from '../utils/entitlement';
|
||||
|
||||
// The paywall. LITE keeps every look previewable and editable; this sheet is
|
||||
// the only place the tier is sold, and Google Play does the selling — the price
|
||||
// shown is the one Play returned for the product.
|
||||
//
|
||||
// Play requires a way back to an existing purchase on any screen that sells
|
||||
// one, so RESTORE is a peer of BUY, not a footnote.
|
||||
|
||||
const PERKS = [
|
||||
'No RECIPESCAM mark on your exports',
|
||||
'HDF effect',
|
||||
`${PRO_FRAMES.length} printed frames`,
|
||||
'GPS location watermark',
|
||||
'Every platform font for your watermark',
|
||||
'Unlimited recipes and favorites',
|
||||
];
|
||||
|
||||
export default function ProModal({ visible, onClose }: { visible: boolean; onClose: () => void }) {
|
||||
const { owned, price, busy, error } = useProUnlock();
|
||||
|
||||
return (
|
||||
<Modal visible={visible} transparent animationType="fade" onRequestClose={onClose}>
|
||||
<View className="flex-1 bg-black/70 items-center justify-center px-4">
|
||||
<TouchableOpacity className="absolute inset-0" activeOpacity={1} onPress={onClose} />
|
||||
<View
|
||||
className="w-full max-w-md rounded-2xl border border-zinc-800/70 p-5"
|
||||
style={{ backgroundColor: 'rgba(8,8,10,0.97)', maxHeight: '85%' }}
|
||||
>
|
||||
<View className="flex-row items-center justify-between mb-3">
|
||||
<View className="flex-row items-center">
|
||||
<Zap size={13} color="#f59e0b" strokeWidth={1.75} />
|
||||
<Text className="text-zinc-300 font-mono text-xs font-bold tracking-widest ml-2">
|
||||
RECIPESCAM PRO
|
||||
</Text>
|
||||
</View>
|
||||
<TouchableOpacity onPress={onClose} hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }}>
|
||||
<X size={15} color="#71717a" />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
<ScrollView showsVerticalScrollIndicator={false}>
|
||||
<Text className="text-zinc-500 font-mono text-[10px] leading-4">
|
||||
One payment, kept on your Google account. Nothing to type in, and it comes back on
|
||||
every phone that has the app.
|
||||
</Text>
|
||||
|
||||
<View className="mt-3 space-y-1.5">
|
||||
{PERKS.map((p) => (
|
||||
<View key={p} className="flex-row items-start">
|
||||
<Text className="text-amber-500 font-mono text-[11px] mr-2">+</Text>
|
||||
<Text className="text-zinc-300 font-mono text-[11px] flex-1">{p}</Text>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
|
||||
{owned ? (
|
||||
<View className="mt-4 rounded-xl border border-amber-500/40 bg-amber-500/10 px-3 py-2.5">
|
||||
<Text className="text-amber-500 font-mono text-[11px] font-bold tracking-widest">
|
||||
PRO ACTIVE
|
||||
</Text>
|
||||
<Text className="text-zinc-500 font-mono text-[10px] mt-1">
|
||||
Every look exports clean: no mark, no limits.
|
||||
</Text>
|
||||
</View>
|
||||
) : (
|
||||
<TouchableOpacity
|
||||
onPress={buyPro}
|
||||
disabled={busy}
|
||||
activeOpacity={0.7}
|
||||
className={`mt-4 rounded-xl border px-3 py-3 items-center ${
|
||||
busy ? 'border-zinc-800/70 bg-black/30' : 'border-amber-500/70 bg-amber-500/15'
|
||||
}`}
|
||||
>
|
||||
<Text
|
||||
className={`font-mono text-[11px] font-bold tracking-widest ${
|
||||
busy ? 'text-zinc-500' : 'text-amber-500'
|
||||
}`}
|
||||
>
|
||||
{busy ? 'CONTACTING GOOGLE PLAY…' : price ? `UNLOCK PRO — ${price}` : 'UNLOCK PRO'}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
|
||||
<TouchableOpacity
|
||||
onPress={restorePro}
|
||||
disabled={busy}
|
||||
activeOpacity={0.7}
|
||||
className="mt-2 flex-row items-center justify-center rounded-xl border border-zinc-800/70 bg-black/30 px-3 py-2.5"
|
||||
>
|
||||
<RefreshCw size={11} color="#a1a1aa" strokeWidth={1.75} />
|
||||
<Text className="text-zinc-400 font-mono text-[10px] font-bold tracking-widest ml-2">
|
||||
RESTORE PURCHASES
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
|
||||
{error ? (
|
||||
<Text className="text-zinc-500 font-mono text-[10px] mt-2.5 leading-4">{error}</Text>
|
||||
) : null}
|
||||
|
||||
<Text className="text-zinc-600 font-mono text-[10px] mt-3 leading-4">
|
||||
Bought already but the app still says LITE? Restore, or check that you are signed in
|
||||
to the Google account that paid.
|
||||
</Text>
|
||||
</ScrollView>
|
||||
</View>
|
||||
</View>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -286,8 +286,8 @@ export default function RecipeCreateModal({
|
||||
highlight: int(fHigh, simAdj.highlight, -10, 10),
|
||||
shadow: int(fShadow, simAdj.shadow, -10, 10),
|
||||
saturation: int(fColor, simAdj.saturation, -10, 10),
|
||||
denoise: int(fNr, simAdj.denoise, 0, 10),
|
||||
sharpening: int(fSharp, simAdj.sharpening, 0, 10),
|
||||
denoise: int(fNr, simAdj.denoise, -10, 10),
|
||||
sharpening: int(fSharp, simAdj.sharpening, -10, 10),
|
||||
clarity: int(fClarity, simAdj.clarity, -10, 10),
|
||||
grain: int(grain, seedAdj.grain, 0, 10),
|
||||
colorChrome: cc as ColorAdjustments['colorChrome'],
|
||||
@@ -313,8 +313,8 @@ export default function RecipeCreateModal({
|
||||
{ label: 'HIGHLIGHT', value: fHigh, set: setFHigh },
|
||||
{ label: 'SHADOW', value: fShadow, set: setFShadow },
|
||||
{ label: 'COLOR', value: fColor, set: setFColor },
|
||||
{ label: 'NOISE RED.', value: fNr, set: setFNr, ph: '0-10' },
|
||||
{ label: 'SHARPENING', value: fSharp, set: setFSharp, ph: '0-10' },
|
||||
{ label: 'NOISE RED.', value: fNr, set: setFNr, ph: '-10..10' },
|
||||
{ label: 'SHARPENING', value: fSharp, set: setFSharp, ph: '-10..10' },
|
||||
{ label: 'CLARITY', value: fClarity, set: setFClarity },
|
||||
{ label: 'EXPOSURE COMP.', value: fEv, set: setFEv, ph: 'EV' },
|
||||
];
|
||||
|
||||
@@ -37,8 +37,11 @@ interface SettingsModalProps {
|
||||
onGpsEnabled: (enabled: boolean) => void;
|
||||
startupMode: StartupMode;
|
||||
onStartupMode: (mode: StartupMode) => void;
|
||||
// LITE vs PRO, decided at build time (no key, nothing to enter here).
|
||||
// LITE vs PRO: the PRO apk is born owned, the free one buys the tier through
|
||||
// Google Play (see src/utils/proUnlock.ts). Nothing to enter here either way —
|
||||
// the LITE row just opens the paywall.
|
||||
pro: boolean;
|
||||
onUpgrade: () => void;
|
||||
}
|
||||
|
||||
function ChipRow({ chips }: { chips: Chip[] }) {
|
||||
@@ -101,6 +104,7 @@ export default function SettingsModal({
|
||||
startupMode,
|
||||
onStartupMode,
|
||||
pro,
|
||||
onUpgrade,
|
||||
}: SettingsModalProps) {
|
||||
const [showCredits, setShowCredits] = useState(false);
|
||||
// The RN Modal is its own dialog window, so the IME does not resize it and
|
||||
@@ -262,13 +266,15 @@ export default function SettingsModal({
|
||||
<SectionLabel>PRO</SectionLabel>
|
||||
<ChipRow
|
||||
chips={[
|
||||
{ key: 'plan', label: pro ? 'PRO ACTIVE' : 'LITE', active: pro, disabled: true, onPress: () => {} },
|
||||
pro
|
||||
? { key: 'plan', label: 'PRO ACTIVE', active: true, disabled: true, onPress: () => {} }
|
||||
: { key: 'plan', label: 'UPGRADE TO PRO', active: false, onPress: onUpgrade },
|
||||
]}
|
||||
/>
|
||||
<Text className="text-zinc-600 font-mono text-[10px] mt-1.5 ml-1">
|
||||
{pro
|
||||
? 'Every look exports clean: no mark, no limits.'
|
||||
: 'LITE is free forever. Any preset, frame and watermark can be previewed and edited, but a PRO look cannot be exported and every export carries the RECIPESCAM mark.'}
|
||||
: 'LITE is free forever. Any preset, frame and watermark can be previewed and edited, but a PRO look cannot be exported and every export carries the RECIPESCAM mark. One payment unlocks everything.'}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React, { useState } from 'react';
|
||||
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 { Plus, Pencil, Undo2 } from 'lucide-react-native';
|
||||
import * as Haptics from 'expo-haptics';
|
||||
|
||||
interface TopBarProps {
|
||||
@@ -16,9 +16,23 @@ interface TopBarProps {
|
||||
// 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;
|
||||
// BACK (undo): one step back through the look's history. PRO only — App
|
||||
// refuses and offers the paywall when a LITE build taps it. No count is
|
||||
// drawn: the icon turns amber while there is a step to go back to.
|
||||
onUndo: () => void;
|
||||
undoDepth: number;
|
||||
}
|
||||
|
||||
export default function TopBar({ mode, recipeName, onSaveRecipe, onOverwriteRecipe, onEditRecipe, onExportShare }: TopBarProps) {
|
||||
export default function TopBar({
|
||||
mode,
|
||||
recipeName,
|
||||
onSaveRecipe,
|
||||
onOverwriteRecipe,
|
||||
onEditRecipe,
|
||||
onExportShare,
|
||||
onUndo,
|
||||
undoDepth,
|
||||
}: TopBarProps) {
|
||||
const insets = useSafeAreaInsets();
|
||||
const [modalVisible, setModalVisible] = useState(false);
|
||||
const [newRecipeName, setNewRecipeName] = useState('');
|
||||
@@ -75,6 +89,17 @@ export default function TopBar({ mode, recipeName, onSaveRecipe, onOverwriteReci
|
||||
</View>
|
||||
|
||||
<View className="flex-row items-center space-x-2">
|
||||
{/* BACK: the history control the chip row used to carry, sitting
|
||||
between the status block and SAVE & SHARE. Lucide (ISC) vector
|
||||
— no icon font to ship or license. */}
|
||||
<TouchableOpacity
|
||||
onPress={onUndo}
|
||||
accessibilityLabel="BACK"
|
||||
className="rounded-full border border-zinc-700/80 bg-black/45 p-2.5"
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<Undo2 size={13} color={undoDepth > 0 ? '#f59e0b' : '#52525b'} />
|
||||
</TouchableOpacity>
|
||||
{onEditRecipe && (
|
||||
<TouchableOpacity
|
||||
onPress={onEditRecipe}
|
||||
|
||||
@@ -468,13 +468,22 @@ const Viewfinder = forwardRef<ViewfinderHandle, ViewfinderProps>(function Viewfi
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[]
|
||||
);
|
||||
// Sharpening (-10..10): below 0 the knob softens the frame, the same blur the
|
||||
// export applies. Own sync so it wraps the clarity filter instead of the two
|
||||
// knobs clobbering each other.
|
||||
const softenSync = useMemo(
|
||||
() => createSynchronizable<number>(adjustments.sharpening ?? 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);
|
||||
claritySync.setBlocking(adjustments.clarity ?? 0);
|
||||
}, [colorMatrix, toneParams, cinemaParams, adjustments.hdf, adjustments.clarity, colorMatrixSync, toneSync, cinemaSync, hdfSync, claritySync]);
|
||||
softenSync.setBlocking(adjustments.sharpening ?? 0);
|
||||
}, [colorMatrix, toneParams, cinemaParams, adjustments.hdf, adjustments.clarity, adjustments.sharpening, colorMatrixSync, toneSync, cinemaSync, hdfSync, claritySync, softenSync]);
|
||||
|
||||
// Camera controller lives behind SkiaCamera. The camera AE bias is never
|
||||
// written: EV (user + metering offset) is purely software matrix gain, for
|
||||
@@ -1134,38 +1143,50 @@ const Viewfinder = forwardRef<ViewfinderHandle, ViewfinderProps>(function Viewfi
|
||||
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.
|
||||
// blur below. SHARPENING < 0 rides the same slot: it softens the frame
|
||||
// the exported file gets too. Both sit on the base paint, under the
|
||||
// glow, and compose (soften wraps whatever clarity built).
|
||||
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,
|
||||
const softenV = softenSync.getDirty();
|
||||
const cv = typeof clarityV === 'number' && Number.isFinite(clarityV) ? clarityV : 0;
|
||||
const sv = typeof softenV === 'number' && Number.isFinite(softenV) ? softenV : 0;
|
||||
if (cv !== 0 || sv < 0) {
|
||||
const a = (cv / 10) * 0.8;
|
||||
let lensFilter =
|
||||
cv > 0
|
||||
? Skia.ImageFilter.MakeMatrixConvolution(
|
||||
3,
|
||||
3,
|
||||
[0, -a, 0, -a, 1 + 4 * a, -a, 0, -a, 0],
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
T_CLAMP,
|
||||
true,
|
||||
null
|
||||
);
|
||||
if (clarityFilter != null) paint.setImageFilter(clarityFilter);
|
||||
)
|
||||
: cv < 0
|
||||
? Skia.ImageFilter.MakeBlur(
|
||||
Math.abs(cv / 10) * 4,
|
||||
Math.abs(cv / 10) * 4,
|
||||
T_CLAMP,
|
||||
null
|
||||
)
|
||||
: null;
|
||||
if (sv < 0) {
|
||||
lensFilter = Skia.ImageFilter.MakeBlur(
|
||||
Math.abs(sv / 10) * 3,
|
||||
Math.abs(sv / 10) * 3,
|
||||
T_CLAMP,
|
||||
lensFilter
|
||||
);
|
||||
}
|
||||
if (lensFilter != null) paint.setImageFilter(lensFilter);
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('Clarity filter build failed: ' + e);
|
||||
console.warn('Lens filter build failed: ' + e);
|
||||
}
|
||||
const tone = toneSync.getDirty();
|
||||
const cinema = cinemaSync.getDirty();
|
||||
@@ -1393,7 +1414,11 @@ const Viewfinder = forwardRef<ViewfinderHandle, ViewfinderProps>(function Viewfi
|
||||
[colorMatrixSync, toneSync, cinemaSync, hdfSync, claritySync, toneFailLogged, feedRectSync, frameBeatSync]
|
||||
);
|
||||
|
||||
const grainOpacity = adjustments.grain / 20;
|
||||
// NOISE REDUCTION below 0 puts grain back instead of smoothing it away, so a
|
||||
// negative NR and GRAIN share one overlay (capped at 10 → 0.5), exactly as
|
||||
// the export pass does.
|
||||
const grainOpacity =
|
||||
Math.min(10, Math.max(0, adjustments.grain) + Math.max(0, -(adjustments.denoise ?? 0))) / 20;
|
||||
|
||||
// Library-still viewer: pinch-zoom (2 fingers) + double-tap zoom at the tap
|
||||
// point / double-tap again to reset to 100%. Transform is a Skia matrix
|
||||
@@ -2844,10 +2869,16 @@ const Viewfinder = forwardRef<ViewfinderHandle, ViewfinderProps>(function Viewfi
|
||||
<SkiaImage image={skiaImage} fit={fit} rect={rectNode} />
|
||||
);
|
||||
// Mist (negative clarity) = export pass 4's blur; sigma in original px.
|
||||
if (libClarity < 0) {
|
||||
// Sharpening < 0 rides the same wrap: the export runs a second Gaussian
|
||||
// after the mist, and two Gaussians in series are one of sqrt(a^2 + b^2),
|
||||
// so a single pass with that sigma matches the file.
|
||||
const libMist = libClarity < 0 ? Math.abs(libClarity / 10) * 4 * sx : 0;
|
||||
const libSoften = (Math.max(0, -(adjustments.sharpening ?? 0)) / 10) * 3 * sx;
|
||||
const libBlur = Math.sqrt(libMist * libMist + libSoften * libSoften);
|
||||
if (libBlur > 0) {
|
||||
photo = (
|
||||
<Group>
|
||||
<Blur blur={Math.abs(libClarity / 10) * 4 * sx} mode="clamp" />
|
||||
<Blur blur={libBlur} mode="clamp" />
|
||||
{photo}
|
||||
</Group>
|
||||
);
|
||||
|
||||
+2
-2
@@ -71,13 +71,13 @@ export interface ColorAdjustments {
|
||||
wbBlue?: number; // Fuji WB fine-tune, Blue axis (-9..9) — extra blue-channel gain
|
||||
highlight: number; // -10 to +10 (- pulls highlights down / roll, + brightens)
|
||||
shadow: number; // -10 to +10 (+ lifts shadows, - deepens)
|
||||
denoise: number; // 0 to 10 (mapped to blur sigma)
|
||||
denoise: number; // -10 to +10 (+ = blur sigma; - = add film grain back)
|
||||
clarity: number; // -10 to +10 (mapped to matrix convolution / bloom)
|
||||
grain: number; // 0 to 10 (mapped to noise turbulence opacity/scale)
|
||||
colorChrome: 'none' | 'weak' | 'strong'; // Chrome effect
|
||||
colorChromeBlue: 'none' | 'weak' | 'strong'; // Chrome effect (Blue)
|
||||
dynamicRange: 'auto' | 100 | 200 | 400; // Fuji-style DR tone curve (auto/100 = no extra curve)
|
||||
sharpening: number; // 0 to 10 (output screen sharpen strength)
|
||||
sharpening: number; // -10 to +10 (+ = screen sharpen; - = soften the frame)
|
||||
hdf?: number; // 0 to 10 (Highlight Diffusion Filter: glow on the highlights only)
|
||||
vignette?: number; // 0 to 10 (darken the four corners; 0 = off)
|
||||
vibrance?: number; // -10 to +10 (chroma-masked saturation: vivid colours move least)
|
||||
|
||||
@@ -101,6 +101,13 @@ export const CREDITS: CreditEntry[] = [
|
||||
copyright: "Copyright (c) 2015-present 650 Industries, Inc. (aka Expo)",
|
||||
url: "https://docs.expo.dev/versions/latest/sdk/haptics/",
|
||||
},
|
||||
{
|
||||
name: "expo-iap",
|
||||
version: "5.6.0",
|
||||
license: "MIT",
|
||||
copyright: "Copyright (c) 2025 hyochan",
|
||||
url: "https://github.com/hyodotdev/openiap/tree/main/libraries/expo-iap#readme",
|
||||
},
|
||||
{
|
||||
name: "expo-keep-awake",
|
||||
version: "57.0.1",
|
||||
|
||||
@@ -1,14 +1,10 @@
|
||||
import { FrameId } from '../types';
|
||||
import { IS_PRO_BUILD } from '../provariant';
|
||||
|
||||
// LITE (free) vs PRO (the paid build). The two are separate apks built from this
|
||||
// one tree: the tier is fixed at build time by src/provariant.ts (rewritten by
|
||||
// tools/set-variant.mjs, `npm run apk:lite|apk:pro`) and nothing in a LITE apk
|
||||
// can change it afterwards — no key, no server, no stored flag.
|
||||
|
||||
// Constant for the life of the build, so every `!IS_PRO` branch below is dead
|
||||
// code the bundler is free to drop.
|
||||
export const IS_PRO = IS_PRO_BUILD;
|
||||
// LITE (free) vs PRO. Two apks come out of this one tree: the PRO apk
|
||||
// (src/provariant.ts, rewritten by tools/set-variant.mjs) is born owned, while
|
||||
// the LITE apk is the free build and buys the tier through Google Play. The
|
||||
// tier itself is runtime state, so it lives in src/utils/proUnlock.ts and every
|
||||
// caller takes it from there (useProUnlock) or by prop.
|
||||
|
||||
// The gate is on the FILE, not on the preview: a LITE user composes with any
|
||||
// look and compares it before/after — only the render/export of a PRO look is
|
||||
|
||||
@@ -335,6 +335,24 @@ export async function processAndExportPhoto(
|
||||
}
|
||||
}
|
||||
|
||||
// Negative SHARPENING is the amount of digital edge enhancement to take
|
||||
// back OUT. It runs HERE, inside the photo's own filter chain, because the
|
||||
// post-grain twin (step 9) blurred the grain overlay away: NR < 0 plus
|
||||
// SHARPENING < 0 exported as flat mush with no grain at all.
|
||||
const sharpKnob = adjustments.sharpening ?? 0;
|
||||
if (sharpKnob < 0) {
|
||||
const softenSigma = (Math.abs(sharpKnob) / 10) * 3;
|
||||
const softenFilter = Skia.ImageFilter.MakeBlur(
|
||||
softenSigma,
|
||||
softenSigma,
|
||||
TileMode.Clamp,
|
||||
imageFilter
|
||||
);
|
||||
if (softenFilter) {
|
||||
imageFilter = softenFilter;
|
||||
}
|
||||
}
|
||||
|
||||
if (imageFilter) {
|
||||
paint.setImageFilter(imageFilter);
|
||||
}
|
||||
@@ -423,8 +441,18 @@ export async function processAndExportPhoto(
|
||||
|
||||
|
||||
// 6. Add Monochrome Grain Overlay
|
||||
if (adjustments.grain > 0) {
|
||||
const grainOpacity = adjustments.grain / 20; // up to 0.5 opacity
|
||||
// NOISE REDUCTION below 0 works the other way round: it hands back the grain
|
||||
// the denoiser would have flattened (a JPEG cannot be un-smoothed, but it
|
||||
// can be re-grained). Same overlay as GRAIN, capped at the GRAIN slider's
|
||||
// own ceiling so a negative NR plus GRAIN never double up.
|
||||
// ponytail: re-grain, not de-noise; add a real grain-preserving NR the day
|
||||
// the pipeline gets a pre-ISP raw frame.
|
||||
const grainAmount = Math.min(
|
||||
10,
|
||||
Math.max(0, adjustments.grain) + Math.max(0, -(adjustments.denoise ?? 0))
|
||||
);
|
||||
if (grainAmount > 0) {
|
||||
const grainOpacity = grainAmount / 20; // up to 0.5 opacity
|
||||
const grainPaint = Skia.Paint();
|
||||
grainPaint.setBlendMode(BlendMode.Overlay);
|
||||
grainPaint.setAlphaf(grainOpacity);
|
||||
@@ -732,8 +760,10 @@ export async function processAndExportPhoto(
|
||||
let resultImage = outputSurface.makeImageSnapshot();
|
||||
// Sharpening knob (0..10) overrides the legacy boolean sharpen flag:
|
||||
// legacy camera captures keep sharpen:true → 0.5 fallback when knob is 0.
|
||||
const sharpKnob = adjustments.sharpening ?? 0;
|
||||
const sharpenAmount = sharpKnob > 0 ? (sharpKnob / 10) * 0.8 : options?.sharpen ? 0.5 : 0;
|
||||
// A negative knob means "no sharpening at all" (its blur already ran at
|
||||
// step 4, under the grain), so the legacy 0.5 fallback must not fire.
|
||||
const sharpenAmount =
|
||||
sharpKnob > 0 ? (sharpKnob / 10) * 0.8 : sharpKnob < 0 ? 0 : options?.sharpen ? 0.5 : 0;
|
||||
if (sharpenAmount > 0) {
|
||||
resultImage = screenSharpenImage(resultImage, sharpenAmount);
|
||||
}
|
||||
|
||||
@@ -73,10 +73,11 @@ export async function processAndExportPhotoNative(
|
||||
// (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
|
||||
// Final screen sharpen (engine #9): the sharpening knob (-10..10) overrides
|
||||
// the legacy boolean sharpen flag (camera captures keep it true → 0.5).
|
||||
const sharpKnob = adjustments.sharpening ?? 0;
|
||||
const sharpenAmount = sharpKnob > 0 ? (sharpKnob / 10) * 0.8 : options?.sharpen ? 0.5 : 0;
|
||||
const sharpenAmount =
|
||||
sharpKnob > 0 ? (sharpKnob / 10) * 0.8 : sharpKnob < 0 ? 0 : options?.sharpen ? 0.5 : 0;
|
||||
const aspect: '4:3' | '3:2' | undefined =
|
||||
options?.aspect && options.aspect !== 'full' && frameId !== 'wallframe'
|
||||
? options.aspect
|
||||
@@ -99,6 +100,8 @@ export async function processAndExportPhotoNative(
|
||||
? { locality: gpsInfo.locality || 'STREET VIEW', timestamp: gpsInfo.timestamp }
|
||||
: null,
|
||||
sharpen: sharpenAmount > 0 ? sharpenAmount : undefined,
|
||||
// Negative knob -> Kotlin soften blur, pre-grain (engine step 4).
|
||||
soften: sharpKnob < 0 ? (Math.abs(sharpKnob) / 10) * 3 : undefined,
|
||||
dpi: options?.dpi && options.dpi > 0 ? options.dpi : undefined,
|
||||
aspect,
|
||||
};
|
||||
|
||||
@@ -134,7 +134,7 @@ export const PARAM_DEFS: {
|
||||
{
|
||||
key: 'denoise',
|
||||
label: 'NOISE REDUCTION',
|
||||
min: 0,
|
||||
min: -10,
|
||||
max: 10,
|
||||
step: 1,
|
||||
defaultValue: 0,
|
||||
@@ -156,7 +156,7 @@ export const PARAM_DEFS: {
|
||||
{
|
||||
key: 'sharpening',
|
||||
label: 'SHARPENING',
|
||||
min: 0,
|
||||
min: -10,
|
||||
max: 10,
|
||||
step: 1,
|
||||
defaultValue: 0,
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
import { useSyncExternalStore } from 'react';
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||
import {
|
||||
fetchProducts,
|
||||
finishTransaction,
|
||||
getAvailablePurchases,
|
||||
initConnection,
|
||||
purchaseErrorListener,
|
||||
purchaseUpdatedListener,
|
||||
requestPurchase,
|
||||
restorePurchases,
|
||||
} from 'expo-iap';
|
||||
|
||||
import { IS_PRO_BUILD } from '../provariant';
|
||||
|
||||
// One product buys the whole tier: PRO removes the mark and opens every gated
|
||||
// look. Google Play owns the purchase — the app only reads it back.
|
||||
export const PRO_SKU = 'recipescam_pro_unlock';
|
||||
|
||||
// Last known answer, so the app opens unlocked while the store query is still
|
||||
// in flight (and offline). The store is the authority: it can revoke it below.
|
||||
const CACHE_KEY = '@recipescam_pro:unlock';
|
||||
|
||||
export interface ProState {
|
||||
owned: boolean;
|
||||
price: string | null;
|
||||
busy: boolean;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
// The PRO apk is sold as-is: that build is born owned and never talks to Play.
|
||||
let state: ProState = { owned: IS_PRO_BUILD, price: null, busy: false, error: null };
|
||||
const listeners = new Set<() => void>();
|
||||
const set = (patch: Partial<ProState>) => {
|
||||
state = { ...state, ...patch };
|
||||
listeners.forEach((l) => l());
|
||||
};
|
||||
const subscribe = (l: () => void) => {
|
||||
listeners.add(l);
|
||||
return () => {
|
||||
listeners.delete(l);
|
||||
};
|
||||
};
|
||||
|
||||
export const getProState = () => state;
|
||||
export const useProUnlock = () => useSyncExternalStore(subscribe, getProState);
|
||||
|
||||
const grant = async () => {
|
||||
set({ owned: true, error: null });
|
||||
await AsyncStorage.setItem(CACHE_KEY, '1').catch(() => {});
|
||||
};
|
||||
|
||||
// Play hands the purchase to the listener. Grant it, then finalise: an Android
|
||||
// purchase left unacknowledged for three days is automatically refunded.
|
||||
// ponytail: the grant is client-side. Server-side verification of
|
||||
// purchaseToken through the Play Developer API is the upgrade if piracy shows up.
|
||||
const handlePurchase = async (purchase: { productId: string } & Record<string, unknown>) => {
|
||||
if (purchase.productId !== PRO_SKU) return;
|
||||
await grant();
|
||||
set({ busy: false });
|
||||
await finishTransaction({ purchase: purchase as never, isConsumable: false }).catch(() => {});
|
||||
};
|
||||
|
||||
let started = false;
|
||||
|
||||
// Called once at startup. A sideloaded build, a phone with no Play account or a
|
||||
// store hiccup must not take the app down: every failure leaves the tier LITE
|
||||
// and lands in `error` for the paywall to show.
|
||||
export async function initProUnlock() {
|
||||
if (started || IS_PRO_BUILD) return;
|
||||
started = true;
|
||||
if ((await AsyncStorage.getItem(CACHE_KEY).catch(() => null)) === '1') set({ owned: true });
|
||||
try {
|
||||
await initConnection();
|
||||
purchaseUpdatedListener((p) => handlePurchase(p as never));
|
||||
purchaseErrorListener((e) => set({ busy: false, error: e.message }));
|
||||
const [products, purchases] = await Promise.all([
|
||||
fetchProducts({ skus: [PRO_SKU], type: 'in-app' }),
|
||||
getAvailablePurchases(),
|
||||
]);
|
||||
const listed = (Array.isArray(products) ? products : []).find((p) => p.id === PRO_SKU);
|
||||
set({ price: listed?.displayPrice ?? null });
|
||||
if (purchases.some((p) => p.productId === PRO_SKU)) await grant();
|
||||
} catch (e) {
|
||||
set({ error: e instanceof Error ? e.message : 'Store unavailable' });
|
||||
}
|
||||
}
|
||||
|
||||
export async function buyPro() {
|
||||
if (state.owned || state.busy) return;
|
||||
set({ busy: true, error: null });
|
||||
try {
|
||||
// Event-based: the result arrives on purchaseUpdatedListener above.
|
||||
await requestPurchase({ request: { google: { skus: [PRO_SKU] } }, type: 'in-app' });
|
||||
} catch (e) {
|
||||
set({ busy: false, error: e instanceof Error ? e.message : 'Purchase failed' });
|
||||
}
|
||||
}
|
||||
|
||||
export async function restorePro() {
|
||||
if (IS_PRO_BUILD || state.busy) return;
|
||||
set({ busy: true, error: null });
|
||||
try {
|
||||
await restorePurchases();
|
||||
const purchases = await getAvailablePurchases();
|
||||
if (purchases.some((p) => p.productId === PRO_SKU)) await grant();
|
||||
set({ busy: false, error: purchases.some((p) => p.productId === PRO_SKU) ? null : 'No PRO purchase found on this account' });
|
||||
} catch (e) {
|
||||
set({ busy: false, error: e instanceof Error ? e.message : 'Restore failed' });
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user