Give LITE the bundled recipes, three of its own, and its own mark

LITE used to hand back the RECIPESCAM mark instead of the user's own
watermark and treated every saved recipe as PRO. That is the wrong half of
the product: the looks the user dials in are the reason to shoot, the
printing and the paid extras are the reason to pay.

What LITE now owns outright: the eight film sims, the bundled recipes, up
to three recipes of its own, its own watermark text (in one basic face,
the platform families stay PRO), the plain frames, and FAVORITED. What
needs PRO: the three printed frames, the GPS stamp, the other fonts, and
the next recipe past the limit.

- entitlement: `Look` is {frame, gpsWm}; `isFreeRecipe` is gone, the
  recipe gate is a count now, decided in App (`liteSaveBlocked`).
- export: a LITE export draws the custom mark AND the RECIPESCAM mark, so
  `liteMark` joins `watermark` in the options and the stamp block loops
  over both with one set of bounds.
- panel: past the limit the strip's trailing chip is a `+` carrying the
  PRO tag, which names the build instead of opening the form.
This commit is contained in:
2026-09-14 21:01:13 +07:00
parent 499e61644d
commit 06d7e8f384
4 changed files with 91 additions and 61 deletions
+24 -25
View File
@@ -115,7 +115,9 @@ export default function App() {
const [adjustments, setAdjustments] = useState<ColorAdjustments>({ ...DEFAULT_ADJUSTMENTS });
const [selectedFrame, setSelectedFrame] = useState<FrameId>('none');
const [useGeotag, setUseGeotag] = useState(true);
// PRO only: the GPS stamp is a paid look, so a LITE session starts without
// it and `handleToggleGeotag` refuses to turn it on.
const [useGeotag, setUseGeotag] = useState(pro);
// GPS master switch (SETTINGS -> GPS): may the app read the device's
// location at all? ON resolves a fix at capture and reverse-geocodes it;
// OFF reads nothing. The GPS WATERMARK chip is separate — it only decides
@@ -435,7 +437,7 @@ export default function App() {
if (session.adjustments)
setAdjustments({ ...DEFAULT_ADJUSTMENTS, ...session.adjustments });
if (session.selectedFrame) setSelectedFrame(session.selectedFrame);
if (session.useGeotag !== undefined) setUseGeotag(session.useGeotag);
if (session.useGeotag !== undefined) setUseGeotag(pro && session.useGeotag);
if (session.aspectRatio === 'full' || session.aspectRatio === '4:3' || session.aspectRatio === '3:2') setAspectRatio(session.aspectRatio);
if (session.cropRatio) setCropRatio(session.cropRatio);
if (session.cropRect && typeof session.cropRect === 'object') setCropRect({ ...DEFAULT_CROP_RECT, ...session.cropRect });
@@ -460,7 +462,7 @@ export default function App() {
y: clamp01(raw.y, 0.5),
color: typeof raw.color === 'string' ? raw.color : undefined,
size: typeof raw.size === 'number' && Number.isFinite(raw.size) ? raw.size : undefined,
font: typeof raw.font === 'string' ? raw.font : null,
font: pro && typeof raw.font === 'string' ? raw.font : null,
});
}
if (session.gpsWm && typeof session.gpsWm === 'object') {
@@ -689,6 +691,10 @@ export default function App() {
};
const handleToggleGeotag = async (enabled: boolean) => {
if (enabled && !pro) {
promptProFeature('The GPS watermark');
return;
}
setUseGeotag(enabled);
if (selectedRecipe) {
setSelectedRecipe({
@@ -942,10 +948,6 @@ export default function App() {
// the background. The write is the only failure worth logging — the look is
// untouched either way.
const handleToggleFavorite = (id: string) => {
if (!pro) {
promptProFeature('Favoriting recipes');
return;
}
const on = !favoriteIds.includes(id);
setFavoriteIds((ids) => (on ? [...ids, id] : ids.filter((x) => x !== id)));
setFavoriteId(id, on).catch((e) => console.warn('Failed to save favorite:', e));
@@ -1020,14 +1022,9 @@ export default function App() {
// Only the EXPORT consults it: the preview shows every look for free, so the
// answer is computed where the file is about to be written, not on the chips.
// PRO unlocks every look, so a paying user has nothing left in use: only a
// Lite session can trip the two gates below.
const proLook = pro
? null
: proLookInUse({
recipeId: selectedRecipe?.id,
frame: selectedFrame,
customWm: customWmApplied.enabled,
});
// Lite session can trip them: a printed frame it previewed, or a GPS stamp
// that arrived with a restored session.
const proLook = pro ? null : proLookInUse({ frame: selectedFrame, gpsWm: useGeotag });
// The native Kotlin exporter has no text-watermark draw, so a Lite export
// (which always carries the mark) must take the Skia engine.
const nativeExport = NATIVE_EXPORT && pro;
@@ -1059,21 +1056,22 @@ export default function App() {
size?: number;
font?: string | null;
} | null =>
// Lite: the user's own mark can never reach the file (that look is gated at
// the shutter); every Lite export carries the RECIPESCAM mark instead.
!pro
? LITE_MARK
: customWmApplied.enabled && customWmApplied.text.trim().length > 0
// The user's own mark is free in both tiers. LITE only narrows the FACE it
// may use (one basic font), not the mark itself.
customWmApplied.enabled && customWmApplied.text.trim().length > 0
? {
text: customWmApplied.text.trim(),
x: customWmApplied.x,
y: customWmApplied.y,
color: customWmApplied.color,
size: customWmApplied.size,
font: customWmApplied.font,
font: pro ? customWmApplied.font : null,
}
: null;
// The free build's own mark, burned in on top of the custom one.
const liteMark = pro ? null : LITE_MARK;
// `burst` marks one frame of a held-shutter burst: the loop already gave the
// single haptic and the click would machine-gun, so both are muted, and RAW
// is skipped (a DNG per frame doubles the exposures the burst has to fit).
@@ -1184,6 +1182,7 @@ export default function App() {
sharpen: true,
aspect: aspectRatio === 'full' ? undefined : aspectRatio,
watermark: wmForExport(),
liteMark,
watermarkRotation: wmRotation,
gpsWatermark: gpsWm,
wallframeLandscape: wallLandscape,
@@ -1278,6 +1277,7 @@ export default function App() {
// EXIF (camera, dates, GPS) is carried into the file.
exported: true,
watermark: wmForExport(),
liteMark,
watermarkRotation: wmRotation,
gpsWatermark: gpsWm,
wallframeLandscape: wallLandscape,
@@ -1302,6 +1302,7 @@ export default function App() {
// EXIF (camera, dates, GPS) is carried into the file.
exported: true,
watermark: wmForExport(),
liteMark,
watermarkRotation: wmRotation,
gpsWatermark: gpsWm,
wallframeLandscape: wallLandscape,
@@ -1469,10 +1470,6 @@ export default function App() {
// parameter row — otherwise a stale slider row (and its image-drag target)
// would survive into the next panel.
const handleToggleTab = (tab: TabId) => {
if (tab === 'favorites' && !pro) {
promptProFeature('The FAVORITED tab');
return;
}
setOpenTab((prev) => (prev === tab ? null : tab));
setOpenParamKey(null);
};
@@ -1629,6 +1626,8 @@ export default function App() {
onToggleGeotag={handleToggleGeotag}
onSaveRecipe={handleSaveRecipe}
onImportRecipe={handleImportRecipe}
recipeLimitReached={liteSaveBlocked}
onRecipeLimit={promptRecipeLimit}
onDeleteRecipe={handleDeleteRecipe}
customWm={customWm}
onUpdateCustomWm={handleUpdateCustomWm}