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:
@@ -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