Ship HDF EFFECT to PRO only, and clean the build for Play
Rename LIGHT GLOW to HDF EFFECT and gate it behind PRO: LITE renders the chip greyed out with a PRO tag and cannot open its slider; restoration of older settings that carry hdf > 0 still blocks export in LITE. Play/licence pass: - drop unused permissions (ACTIVITY_RECOGNITION, READ_MEDIA_AUDIO/VIDEO, SYSTEM_ALERT_WINDOW) and requestLegacyExternalStorage - rename the Leica sims to LC STREETLIFE CLASSIC/VIVID, drop the brand from comments; fix app name text, canvaskit copyright, LICENSE owner - remove expo-image-picker and expo-sensors (no JS import), delete dead exports (computeMatrixTint, cinemaSeasonName, base64 helpers, formatCoordinate) - versionCode 3 / versionName 1.2.1, drop the personal email from About - add docs/privacy-policy.html for the Play listing Size: R8 + shrinkResources + bundle compression + ABI trim, 161 MB -> 76 MB.
This commit is contained in:
@@ -168,6 +168,7 @@ interface ParamDef {
|
||||
step: number;
|
||||
display: (v: number) => string;
|
||||
onChange: (v: number) => void;
|
||||
pro?: boolean; // PRO-only: the LITE build cannot open this row
|
||||
}
|
||||
|
||||
const haptic = () => Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
||||
@@ -420,6 +421,10 @@ export default function AdjustmentPanel({
|
||||
label: `${p.label.toUpperCase()} ${p.display(p.value)}`,
|
||||
active: openParam === p.key,
|
||||
amberValue: openParam !== p.key && p.value !== p.default,
|
||||
// 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,
|
||||
onPress: () => toggleParam(p.key),
|
||||
}));
|
||||
|
||||
@@ -465,6 +470,7 @@ export default function AdjustmentPanel({
|
||||
label: d.label,
|
||||
value: d.get(a),
|
||||
default: d.defaultValue,
|
||||
pro: d.pro,
|
||||
min: d.min,
|
||||
max: d.max,
|
||||
step: d.step,
|
||||
|
||||
@@ -8,8 +8,8 @@ import { CREDITS, LICENSE_TEXTS, CreditEntry } from '../utils/credits';
|
||||
// each project row links to its homepage, each license group expands to the
|
||||
// full license text embedded in the app.
|
||||
|
||||
const APP_NAME = 'recipescamera';
|
||||
const APP_VERSION = '1.2'; // keep in sync with app.json
|
||||
const APP_NAME = 'RecipesCam';
|
||||
const APP_VERSION = '1.2.1'; // keep in sync with app.json
|
||||
|
||||
function Group({
|
||||
license,
|
||||
@@ -134,16 +134,6 @@ export default function CreditsModal({
|
||||
</Text>
|
||||
<View className="flex-row flex-wrap items-center mt-1">
|
||||
<Text className="text-zinc-400 font-mono text-[10px]">Author: Lộc Phạm</Text>
|
||||
<TouchableOpacity
|
||||
onPress={() => Linking.openURL('mailto:tranloclqd@gmail.com')}
|
||||
accessibilityLabel="Email author"
|
||||
className="flex-row items-center ml-3"
|
||||
hitSlop={{ top: 6, bottom: 6, left: 6, right: 6 }}
|
||||
>
|
||||
<Text className="text-amber-500/90 font-mono text-[10px] underline">
|
||||
tranloclqd@gmail.com
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
<Text className="text-zinc-600 font-mono text-[10px] mt-1">
|
||||
© 2026 Lộc Phạm · Built with React Native + Expo; icons by Lucide;
|
||||
|
||||
@@ -72,13 +72,13 @@ export default function PhotoViewerModal({
|
||||
if (await Sharing.isAvailableAsync()) {
|
||||
await Sharing.shareAsync(photo.uri, {
|
||||
mimeType: 'image/jpeg',
|
||||
dialogTitle: `Share with CamRecipe Pro - Preset: ${photo.recipeName}`,
|
||||
dialogTitle: `Share with RecipesCam - Preset: ${photo.recipeName}`,
|
||||
UTI: 'public.jpeg',
|
||||
});
|
||||
} else {
|
||||
await Share.share({
|
||||
url: photo.uri,
|
||||
message: `Captured with CamRecipe Pro - Preset: ${photo.recipeName}`,
|
||||
message: `Captured with RecipesCam - Preset: ${photo.recipeName}`,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
|
||||
@@ -343,7 +343,7 @@ export default function RecipeCreateModal({
|
||||
value={name}
|
||||
onChangeText={setName}
|
||||
selectTextOnFocus
|
||||
placeholder="Recipe name (e.g. Fuji Retro)"
|
||||
placeholder="Recipe name (e.g. Retro Film)"
|
||||
placeholderTextColor="#71717a"
|
||||
className="bg-carbon border border-zinc-800 rounded-lg p-2 text-white font-mono text-sm mb-2"
|
||||
/>
|
||||
|
||||
@@ -115,7 +115,7 @@ export default function TopBar({ mode, recipeName, onSaveRecipe, onOverwriteReci
|
||||
<TextInput
|
||||
value={newRecipeName}
|
||||
onChangeText={setNewRecipeName}
|
||||
placeholder="Recipe name (e.g. Fuji Retro)"
|
||||
placeholder="Recipe name (e.g. Retro Film)"
|
||||
placeholderTextColor="#71717a"
|
||||
className="bg-carbon border border-zinc-800 rounded-lg p-3 text-white font-mono text-sm mb-4"
|
||||
autoFocus
|
||||
|
||||
@@ -2797,7 +2797,7 @@ const Viewfinder = forwardRef<ViewfinderHandle, ViewfinderProps>(function Viewfi
|
||||
const cinemaOn = cinemaEffect != null && !!cinemaParams;
|
||||
const useShaderPass = !!imageFitRect && ((toneEffect != null && toneOn) || cinemaOn);
|
||||
|
||||
// ---- CLARITY + LIGHT GLOW on the LIBRARY preview -----------------------
|
||||
// ---- CLARITY + HDF EFFECT on the LIBRARY preview -----------------------
|
||||
// The camera worklet and exportEngine both have these two passes; the three
|
||||
// library branches below draw the photo declaratively and had neither, so a
|
||||
// photo opened from the library ignored both knobs. Same maths as the camera:
|
||||
|
||||
@@ -118,10 +118,6 @@ const KEYS = [
|
||||
'fogR', 'fogG', 'fogB', 'fogAmt',
|
||||
] as const;
|
||||
|
||||
export function cinemaSeasonName(season: CinemaSeason | null | undefined): string | null {
|
||||
return season ? SEASONS[season]?.name ?? null : null;
|
||||
}
|
||||
|
||||
export function getCinemaUniforms(season: CinemaSeason | null | undefined): CinemaUniformSet | null {
|
||||
if (!season) return null;
|
||||
const s = SEASONS[season];
|
||||
|
||||
+3
-23
@@ -139,8 +139,8 @@ export function getSkiaColorMatrix(baseFilter: BaseFilter, adj: ColorAdjustments
|
||||
0, 0, 0, 1, 0,
|
||||
];
|
||||
} else if (baseFilter === 'leica') {
|
||||
// LEITZ STREETLIFE CLASSIC — the Leica character rather than a saturation
|
||||
// push. What makes a Leica file read as one is the separation BETWEEN the
|
||||
// LC STREETLIFE CLASSIC — the rangefinder character rather than a saturation
|
||||
// push. What makes this file read as one is the separation BETWEEN the
|
||||
// hues, so the deepening lives in the off-diagonal terms, not in a global
|
||||
// chroma gain:
|
||||
// - the three rows sum to 1.00/1.00/0.995, so greys stay exactly grey and
|
||||
@@ -158,7 +158,7 @@ export function getSkiaColorMatrix(baseFilter: BaseFilter, adj: ColorAdjustments
|
||||
0, 0, 0, 1, 0,
|
||||
];
|
||||
} else if (baseFilter === 'leica-vivid') {
|
||||
// LEITZ STREETLIFE VIVID — the same Leica channel separation pushed into the
|
||||
// LC STREETLIFE VIVID — the same slanted channel separation pushed into the
|
||||
// digital era: ~20% of chroma gain (still well under Velvia's 39%, so it
|
||||
// sparkles instead of shouting) on the same warm R>G>B bias CLASSIC has, so
|
||||
// VIVID is a brighter read of one look rather than a second, neutral one.
|
||||
@@ -285,26 +285,6 @@ export function applyExposureGain(matrix: number[], evStops: number): number[] {
|
||||
return multiplyMatrices(gainMat, matrix);
|
||||
}
|
||||
|
||||
// Compute a tint color + opacity from the matrix for camera overlay preview
|
||||
// Applies the matrix to white (1,1,1,1) to see what color cast the filter creates
|
||||
export function computeMatrixTint(matrix: number[]): { r: number; g: number; b: number; a: number } {
|
||||
const apply = (r: number, g: number, b: number, a: number) => {
|
||||
const row = (offset: number) =>
|
||||
matrix[offset + 0] * r + matrix[offset + 1] * g + matrix[offset + 2] * b + matrix[offset + 3] * a + matrix[offset + 4];
|
||||
return { r: row(0), g: row(5), b: row(10), a: row(15) };
|
||||
};
|
||||
|
||||
// Apply matrix to neutral white to get the filter's color cast
|
||||
const white = apply(1, 1, 1, 1);
|
||||
// Clamp and compute perceived intensity to determine opacity
|
||||
const clamp = (v: number) => Math.max(0, Math.min(1, v));
|
||||
const intensity = (white.r + white.g + white.b) / 3;
|
||||
// Opacity is higher when the filter deviates from neutral
|
||||
const deviation = Math.abs(white.r - intensity) + Math.abs(white.g - intensity) + Math.abs(white.b - intensity);
|
||||
const opacity = Math.min(0.35, deviation * 2.5);
|
||||
return { r: clamp(white.r), g: clamp(white.g), b: clamp(white.b), a: opacity };
|
||||
}
|
||||
|
||||
// 4x5 Matrix multiplication utility: A * B
|
||||
function multiplyMatrices(a: number[], b: number[]): number[] {
|
||||
const result = new Array(20).fill(0);
|
||||
|
||||
@@ -21,7 +21,7 @@ export const CREDITS: CreditEntry[] = [
|
||||
name: "canvaskit-wasm",
|
||||
version: "0.41.0",
|
||||
license: "BSD-3-Clause",
|
||||
copyright: "Copyright 2020 Google LLC",
|
||||
copyright: "Copyright (c) 2011 Google Inc. All rights reserved.",
|
||||
url: "https://skia.org/",
|
||||
},
|
||||
{
|
||||
@@ -87,6 +87,13 @@ export const CREDITS: CreditEntry[] = [
|
||||
copyright: "Copyright (c) 2015-present 650 Industries, Inc. (aka Expo)",
|
||||
url: "https://docs.expo.dev/versions/latest/sdk/font/",
|
||||
},
|
||||
{
|
||||
name: "expo-document-picker",
|
||||
version: "57.0.2",
|
||||
license: "MIT",
|
||||
copyright: "Copyright (c) 2015-present 650 Industries, Inc. (aka Expo)",
|
||||
url: "https://docs.expo.dev/versions/latest/sdk/document-picker/",
|
||||
},
|
||||
{
|
||||
name: "expo-haptics",
|
||||
version: "57.0.1",
|
||||
@@ -94,13 +101,6 @@ 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-image-picker",
|
||||
version: "57.0.4",
|
||||
license: "MIT",
|
||||
copyright: "Copyright (c) 2015-present 650 Industries, Inc. (aka Expo)",
|
||||
url: "https://docs.expo.dev/versions/latest/sdk/imagepicker/",
|
||||
},
|
||||
{
|
||||
name: "expo-keep-awake",
|
||||
version: "57.0.1",
|
||||
|
||||
@@ -44,19 +44,19 @@ export const FILM_SIMS: FilmSim[] = [
|
||||
{ id: 'sim-astia', name: 'ASTIPES', baseFilter: 'astia', adjustments: { ...DEFAULT_ADJUSTMENTS } },
|
||||
{ id: 'sim-eterna', name: 'ETERNIPES', baseFilter: 'eterna', adjustments: { ...DEFAULT_ADJUSTMENTS } },
|
||||
{ id: 'sim-acros', name: 'ACRIPES', baseFilter: 'monochrome', adjustments: { ...DEFAULT_ADJUSTMENTS } },
|
||||
// Leica digital look (M/Q/SL) is not a film stock, so it ships as its own
|
||||
// The rangefinder digital look (M/Q/SL) is not a film stock, so it ships as its own
|
||||
// pair instead of one look that has to compromise:
|
||||
// CLASSIC — the stock character: deep solid darks, natural shadows, rich but
|
||||
// quiet colour. Its micro-contrast is CLARITY (a local unsharp mask in both
|
||||
// renderers), never a global saturation boost, and a whisker of grain
|
||||
// keeps it feeling like film rather than digital sharpness.
|
||||
// VIVID — the same Leica separation opened up for the digital era: lifted
|
||||
// VIVID — the same channel separation opened up for the digital era: lifted
|
||||
// exposure, lifted shadows, a little colour. It reads bright and flattering
|
||||
// straight off the shutter, no editing pass needed.
|
||||
// Both share the 'leica'/'leica-vivid' matrices; the tonal half is the sim's
|
||||
// own adjustments, which every apply path now carries into the panel.
|
||||
{ id: 'sim-leica', name: 'LEITZ STREETLIFE CLASSIC', baseFilter: 'leica', adjustments: { ...DEFAULT_ADJUSTMENTS, clarity: 2, grain: 1 } },
|
||||
{ id: 'sim-leica-vivid', name: 'LEITZ STREETLIFE VIVID', baseFilter: 'leica-vivid', adjustments: { ...DEFAULT_ADJUSTMENTS, exposure: 2, saturation: 2, clarity: 2, shadow: 1, highlight: -1 } },
|
||||
{ id: 'sim-leica', name: 'LC STREETLIFE CLASSIC', baseFilter: 'leica', adjustments: { ...DEFAULT_ADJUSTMENTS, clarity: 2, grain: 1 } },
|
||||
{ id: 'sim-leica-vivid', name: 'LC STREETLIFE VIVID', baseFilter: 'leica-vivid', adjustments: { ...DEFAULT_ADJUSTMENTS, exposure: 2, saturation: 2, clarity: 2, shadow: 1, highlight: -1 } },
|
||||
];
|
||||
|
||||
// Film sim → full Recipe (never persisted; sims are applied on the fly).
|
||||
|
||||
@@ -16,9 +16,10 @@ export const IS_PRO = IS_PRO_BUILD;
|
||||
//
|
||||
// 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, one favorite and every parameter. What needs PRO: the three
|
||||
// printed frames, the GPS stamp, the platform's other fonts, the next recipe
|
||||
// past the limit, and the second favorite.
|
||||
// plain frames, one favorite and every parameter except the HDF effect.
|
||||
// What needs PRO: the three printed frames, the GPS stamp, the HDF effect,
|
||||
// the platform's other fonts, the next recipe past the limit, and the second
|
||||
// favorite.
|
||||
|
||||
// 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).
|
||||
@@ -47,6 +48,7 @@ export const LITE_MARK = {
|
||||
export interface Look {
|
||||
frame: FrameId;
|
||||
gpsWm: boolean;
|
||||
hdf: number;
|
||||
}
|
||||
|
||||
// The first PRO feature a look is using, or null when it is LITE-clean. One
|
||||
@@ -54,5 +56,9 @@ export interface Look {
|
||||
export function proLookInUse(look: Look): string | null {
|
||||
if (PRO_FRAMES.includes(look.frame)) return 'A PRO frame';
|
||||
if (look.gpsWm) return 'The GPS watermark';
|
||||
// A LITE build can no longer reach the HDF row (paramDefs marks it PRO and
|
||||
// the panel greys the chip out), but a session or recipe stored before the
|
||||
// gate existed still carries a value: it is caught here, at the render.
|
||||
if (look.hdf > 0) return 'The HDF effect';
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -8,41 +8,6 @@
|
||||
// If the JPEG carries neither JFIF nor EXIF, a minimal JFIF APP0 segment is
|
||||
// inserted right after SOI.
|
||||
|
||||
const B64 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/';
|
||||
|
||||
export function base64ToBytes(b64: string): Uint8Array {
|
||||
const clean = b64.replace(/\s+/g, '');
|
||||
const out: number[] = [];
|
||||
let buffer = 0;
|
||||
let bits = 0;
|
||||
for (const ch of clean) {
|
||||
if (ch === '=') break;
|
||||
const v = B64.indexOf(ch);
|
||||
if (v < 0) continue;
|
||||
buffer = (buffer << 6) | v;
|
||||
bits += 6;
|
||||
if (bits >= 8) {
|
||||
bits -= 8;
|
||||
out.push((buffer >> bits) & 0xff);
|
||||
}
|
||||
}
|
||||
return Uint8Array.from(out);
|
||||
}
|
||||
|
||||
export function bytesToBase64(bytes: Uint8Array): string {
|
||||
let out = '';
|
||||
for (let i = 0; i < bytes.length; i += 3) {
|
||||
const b0 = bytes[i];
|
||||
const b1 = i + 1 < bytes.length ? bytes[i + 1] : 0;
|
||||
const b2 = i + 2 < bytes.length ? bytes[i + 2] : 0;
|
||||
out += B64[b0 >> 2];
|
||||
out += B64[((b0 & 3) << 4) | (b1 >> 4)];
|
||||
out += i + 1 < bytes.length ? B64[((b1 & 15) << 2) | (b2 >> 6)] : '=';
|
||||
out += i + 2 < bytes.length ? B64[b2 & 63] : '=';
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function u16(bytes: Uint8Array, p: number): number {
|
||||
return (bytes[p] << 8) | bytes[p + 1];
|
||||
}
|
||||
|
||||
@@ -97,12 +97,3 @@ export async function reverseGeocode(latitude: number, longitude: number): Promi
|
||||
return null;
|
||||
}
|
||||
|
||||
export function formatCoordinate(val: number, type: 'lat' | 'lon'): string {
|
||||
const absolute = Math.abs(val);
|
||||
const degrees = Math.floor(absolute);
|
||||
const minutes = Math.floor((absolute - degrees) * 60);
|
||||
const seconds = ((absolute - degrees - minutes / 60) * 3600).toFixed(2);
|
||||
const direction = type === 'lat' ? (val >= 0 ? 'N' : 'S') : (val >= 0 ? 'E' : 'W');
|
||||
|
||||
return `${degrees}° ${minutes}' ${seconds}" ${direction}`;
|
||||
}
|
||||
|
||||
@@ -14,6 +14,9 @@ export interface ContinuousParamDef {
|
||||
display: (v: number) => string;
|
||||
get: (a: ColorAdjustments) => number;
|
||||
set: (v: number) => Partial<ColorAdjustments>;
|
||||
// PRO-only knob: the LITE build greys the chip out (PRO corner tag) and
|
||||
// cannot open its row, so the value stays at its default there.
|
||||
pro?: boolean;
|
||||
}
|
||||
|
||||
const sign = (v: number) => (v > 0 ? `+${v}` : String(v));
|
||||
@@ -174,7 +177,8 @@ export const PARAM_DEFS: {
|
||||
},
|
||||
{
|
||||
key: 'hdf',
|
||||
label: 'LIGHT GLOW',
|
||||
label: 'HDF EFFECT',
|
||||
pro: true,
|
||||
min: 0,
|
||||
max: 10,
|
||||
step: 1,
|
||||
|
||||
@@ -106,7 +106,7 @@ vec4 main(vec2 xy) {
|
||||
}
|
||||
`;
|
||||
|
||||
// Bright Pass Filter for the LIGHT GLOW pass (HDF), SkSL over a child image
|
||||
// Bright Pass Filter for the HDF EFFECT pass (HDF), SkSL over a child image
|
||||
// shader — the pattern TONE_SKSL above already proved on device.
|
||||
//
|
||||
// Per channel the old 2.5*in-1.5 curve only zeroed a channel that was dark
|
||||
|
||||
Reference in New Issue
Block a user