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:
@@ -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}
|
||||
|
||||
@@ -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, isFreeRecipe } from '../utils/entitlement';
|
||||
import { IS_PRO, PRO_FRAMES } from '../utils/entitlement';
|
||||
import { PARAM_DEFS, ContinuousParamDef } from '../utils/paramDefs';
|
||||
import { FILM_SIMS, filmSimToRecipe } from '../utils/defaultRecipes';
|
||||
import RecipeCreateModal from './RecipeCreateModal';
|
||||
@@ -112,6 +112,10 @@ interface AdjustmentPanelProps {
|
||||
// IMPORT: pick a recipe file shared from another phone and store it as a
|
||||
// normal custom recipe.
|
||||
onImportRecipe: () => void;
|
||||
// LITE: this many of its own recipes exist, so the next one is the paid
|
||||
// step. The strip's trailing + chip reads it, the save paths gate on it.
|
||||
recipeLimitReached: boolean;
|
||||
onRecipeLimit: () => 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.
|
||||
@@ -245,6 +249,8 @@ export default function AdjustmentPanel({
|
||||
onToggleWallLandscape,
|
||||
onToggleGeotag,
|
||||
onImportRecipe,
|
||||
recipeLimitReached,
|
||||
onRecipeLimit,
|
||||
onDeleteRecipe,
|
||||
customWm,
|
||||
onUpdateCustomWm,
|
||||
@@ -546,6 +552,9 @@ export default function AdjustmentPanel({
|
||||
// manager exposes (the device's own system fonts); DEFAULT keeps the face
|
||||
// the mark shipped with.
|
||||
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' }];
|
||||
const mgr = Skia.FontMgr.System();
|
||||
const names = new Set<string>();
|
||||
for (let i = 0; i < mgr.countFamilies() && names.size < 120; i++) {
|
||||
@@ -723,6 +732,7 @@ export default function AdjustmentPanel({
|
||||
key: 'gps',
|
||||
label: useGeotag ? 'GPS WATERMARK ON' : 'GPS WATERMARK OFF',
|
||||
active: useGeotag,
|
||||
pro: true,
|
||||
onPress: () => onToggleGeotag(!useGeotag),
|
||||
},
|
||||
...(useGeotag ? [groupChip('gpsColor'), groupChip('gpsName'), groupChip('gpsTime')] : []),
|
||||
@@ -730,7 +740,6 @@ export default function AdjustmentPanel({
|
||||
key: 'custom-wm',
|
||||
label: customWm.enabled ? 'CUSTOM WATERMARK ON' : 'CUSTOM WATERMARK OFF',
|
||||
active: customWm.enabled,
|
||||
pro: true,
|
||||
onPress: () => onUpdateCustomWm({ ...customWm, enabled: !customWm.enabled }),
|
||||
},
|
||||
...(wmOn
|
||||
@@ -841,7 +850,6 @@ export default function AdjustmentPanel({
|
||||
key: r.id,
|
||||
label: r.name,
|
||||
active: r.id === currentRecipeId,
|
||||
pro: !isFreeRecipe(r.id),
|
||||
star: true,
|
||||
starred: true,
|
||||
onPress: () => onSelectRecipe(r),
|
||||
@@ -1024,7 +1032,6 @@ export default function AdjustmentPanel({
|
||||
key: r.id,
|
||||
label: r.name,
|
||||
active: r.id === currentRecipeId,
|
||||
pro: !isFreeRecipe(r.id),
|
||||
star: true,
|
||||
starred: favoriteIds.includes(r.id),
|
||||
trash: !!r.isCustom,
|
||||
@@ -1033,6 +1040,16 @@ export default function AdjustmentPanel({
|
||||
onPressDelete: () => onDeleteRecipe(r.id),
|
||||
})
|
||||
)}
|
||||
{/* The next recipe, right after the last one. Past the LITE limit
|
||||
this is the only chip that cannot be pressed: it carries the
|
||||
PRO tag and names the build instead of opening the form. */}
|
||||
{renderChip({
|
||||
key: 'add-recipe',
|
||||
label: '+',
|
||||
active: false,
|
||||
pro: recipeLimitReached,
|
||||
onPress: () => (recipeLimitReached ? onRecipeLimit() : setCreateVisible(true)),
|
||||
})}
|
||||
</ScrollView>
|
||||
</View>
|
||||
)}
|
||||
|
||||
+13
-15
@@ -10,27 +10,27 @@ import { IS_PRO_BUILD } from '../provariant';
|
||||
// code the bundler is free to drop.
|
||||
export const IS_PRO = IS_PRO_BUILD;
|
||||
|
||||
// The gate is on the FILE, not on the preview: a LITE user may pick any preset,
|
||||
// frame or watermark, compose with it and compare before/after — only the
|
||||
// render/export of that look is held back, and every LITE export carries the
|
||||
// mark below.
|
||||
// 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
|
||||
// held back, and every LITE export carries the mark below.
|
||||
//
|
||||
// What LITE owns outright: the eight film sims, the bundled recipes, its own
|
||||
// recipes up to LITE_RECIPE_LIMIT, its own watermark text (in one face), the
|
||||
// plain frames and every parameter. What needs PRO: the three printed frames,
|
||||
// the GPS stamp, the platform's other fonts, and the next recipe past the
|
||||
// limit.
|
||||
|
||||
// Frames on the LITE side: the plain export only. The three printed looks are
|
||||
// PRO (the artwork/card geometry is the paid half of the FRAME tab).
|
||||
export const PRO_FRAMES: FrameId[] = ['classic-white', 'polaroid', 'wallframe'];
|
||||
|
||||
// Presets: the eight film SIMS are free (a base-filter matrix is the camera's
|
||||
// own look); a saved RECIPE — bundled or user-created — is a dialled-in look
|
||||
// and belongs to PRO. Ids are `sim-*` for sims, anything else for recipes.
|
||||
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.
|
||||
// of the stamp canvas, same space the custom mark uses. The user's own mark is
|
||||
// free on top of it.
|
||||
export const LITE_MARK = {
|
||||
text: 'RECIPESCAM',
|
||||
x: 0.55,
|
||||
@@ -40,16 +40,14 @@ export const LITE_MARK = {
|
||||
};
|
||||
|
||||
export interface Look {
|
||||
recipeId?: string | null;
|
||||
frame: FrameId;
|
||||
customWm: boolean;
|
||||
gpsWm: boolean;
|
||||
}
|
||||
|
||||
// The first PRO feature a look is using, or null when it is LITE-clean. One
|
||||
// string, so the caller can name it back to the user in the alert.
|
||||
export function proLookInUse(look: Look): string | null {
|
||||
if (!isFreeRecipe(look.recipeId)) return 'A PRO preset';
|
||||
if (PRO_FRAMES.includes(look.frame)) return 'A PRO frame';
|
||||
if (look.customWm) return 'The custom watermark';
|
||||
if (look.gpsWm) return 'The GPS watermark';
|
||||
return null;
|
||||
}
|
||||
|
||||
+33
-17
@@ -59,6 +59,16 @@ export interface ExportOptions {
|
||||
size?: number;
|
||||
font?: string | null;
|
||||
} | null;
|
||||
// The free build's own mark (RECIPESCAM), drawn in ADDITION to `watermark`:
|
||||
// the custom text is free in LITE, the mark is what LITE leaves behind.
|
||||
liteMark?: {
|
||||
text: string;
|
||||
x: number;
|
||||
y: number;
|
||||
color?: string;
|
||||
size?: number;
|
||||
font?: string | null;
|
||||
} | null;
|
||||
// WATERMARK tab ROTATE chip: clockwise degrees (0/90/180/270) applied to
|
||||
// BOTH the GPS lines and the custom text, each around its own anchor (the
|
||||
// same anchor Viewfinder rotates around, so the preview matches the file).
|
||||
@@ -627,7 +637,8 @@ export async function processAndExportPhoto(
|
||||
// one amber-mono paint path serves both: GPS keeps its two icon lines; the
|
||||
// custom watermark is plain text drawn at the user-dragged position.
|
||||
const customWm = options?.watermark ?? null;
|
||||
if ((useGeotag && gpsInfo) || customWm) {
|
||||
const liteWm = options?.liteMark ?? null;
|
||||
if ((useGeotag && gpsInfo) || customWm || liteWm) {
|
||||
const fontAsset = Asset.fromModule(require('../../assets/Cousine-Regular.ttf'));
|
||||
const iconFontAsset = Asset.fromModule(require('../../assets/NotoEmoji-GPS.ttf'));
|
||||
await Promise.all([fontAsset.downloadAsync(), iconFontAsset.downloadAsync()]);
|
||||
@@ -686,28 +697,33 @@ export async function processAndExportPhoto(
|
||||
// area + ~0.35em drop (the grabbed anchor reads as the text's visual
|
||||
// center, not its baseline) — the exact formula Viewfinder uses, so
|
||||
// preview == export.
|
||||
if (customWm) {
|
||||
if (customWm || liteWm) {
|
||||
// The mark lives on the FRAME: a framed export's canvas IS the
|
||||
// card/frame (see the branches above), so the whole stamp is the
|
||||
// area — the same 0..1 fractions the viewfinder drag produced.
|
||||
const area = { dx: 0, dy: 0, dw: stampW, dh: stampH };
|
||||
const clamp01 = (v: number) => Math.max(0, Math.min(1, v));
|
||||
const wmSize = Math.round(fontSize * (customWm.size ?? 1));
|
||||
const wmFont = customWm.font
|
||||
? Skia.Font(
|
||||
Skia.FontMgr.System().matchFamilyStyle(customWm.font, FontStyle.Normal),
|
||||
wmSize
|
||||
)
|
||||
: Skia.Font(typeface, wmSize);
|
||||
const wmPaint = Skia.Paint();
|
||||
wmPaint.setColor(Skia.Color(customWm.color ?? '#f59e0b'));
|
||||
const tx = area.dx + clamp01(customWm.x) * area.dw;
|
||||
const ty = area.dy + clamp01(customWm.y) * area.dh + Math.round(wmSize * 0.35);
|
||||
const wmRot = options?.watermarkRotation ?? 0;
|
||||
stampCanvas.save();
|
||||
if (wmRot) stampCanvas.rotate(wmRot, tx, ty);
|
||||
stampCanvas.drawText(customWm.text, tx, ty, wmPaint, wmFont);
|
||||
stampCanvas.restore();
|
||||
// Both marks share this path: the user's text (free in every tier)
|
||||
// and, in LITE, the build's own RECIPESCAM mark next to it.
|
||||
for (const mark of [customWm, liteWm]) {
|
||||
if (!mark) continue;
|
||||
const wmSize = Math.round(fontSize * (mark.size ?? 1));
|
||||
const wmFont = mark.font
|
||||
? Skia.Font(
|
||||
Skia.FontMgr.System().matchFamilyStyle(mark.font, FontStyle.Normal),
|
||||
wmSize
|
||||
)
|
||||
: Skia.Font(typeface, wmSize);
|
||||
const wmPaint = Skia.Paint();
|
||||
wmPaint.setColor(Skia.Color(mark.color ?? '#f59e0b'));
|
||||
const tx = area.dx + clamp01(mark.x) * area.dw;
|
||||
const ty = area.dy + clamp01(mark.y) * area.dh + Math.round(wmSize * 0.35);
|
||||
stampCanvas.save();
|
||||
if (wmRot) stampCanvas.rotate(wmRot, tx, ty);
|
||||
stampCanvas.drawText(mark.text, tx, ty, wmPaint, wmFont);
|
||||
stampCanvas.restore();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user