feat: v1.1 — highlight-weighted metering, settings modal, photo viewer, shutter sound control, cleanup

This commit is contained in:
2026-09-09 09:41:55 +07:00
parent e9a9005ef2
commit 09dbc72cce
46 changed files with 1684 additions and 2491 deletions
+42 -3
View File
@@ -1,6 +1,4 @@
import { ColorAdjustments } from '../types';
export type BaseFilter = 'classic-neg' | 'provia' | 'velvia' | 'monochrome' | 'none';
import { ColorAdjustments, BaseFilter } from '../types';
// Kelvin to RGB compensation for White Balance.
// In photography, when WB is set HIGH (e.g. 10000K), the camera understands
@@ -75,6 +73,31 @@ export function getSkiaColorMatrix(baseFilter: BaseFilter, adj: ColorAdjustments
-0.05, -0.05, 1.25, 0, -0.02,
0, 0, 0, 1, 0,
];
} else if (baseFilter === 'classic-chrome') {
// Classic Chrome: muted/deep color with strong tone — desaturated reds and
// greens, slight warm amber in highlights, shadows stay soft (approx).
matrix = [
1.12, -0.03, -0.06, 0, 0.04,
-0.04, 1.0, -0.03, 0, 0.03,
-0.05, -0.06, 0.86, 0, -0.02,
0, 0, 0, 1, 0,
];
} else if (baseFilter === 'astia') {
// Astia: soft, gentle, flattering — low contrast, pastel-leaning colors.
matrix = [
1.0, 0.02, 0.02, 0, 0.01,
0.01, 1.0, 0.02, 0, 0.02,
0.01, 0.01, 1.02, 0, 0.02,
0, 0, 0, 1, 0,
];
} else if (baseFilter === 'eterna') {
// Eterna: cinema — flat (low contrast), muted color, gentle highlight rolloff.
matrix = [
0.94, 0.02, 0.02, 0, 0.02,
0.02, 0.96, 0.02, 0, 0.02,
0.02, 0.02, 0.94, 0, 0.02,
0, 0, 0, 1, 0,
];
} else if (baseFilter === 'monochrome') {
// Acros Monochrome: ITU-R BT.709 luma — pure grayscale, zero color leakage
const r = 0.2126;
@@ -148,6 +171,22 @@ export function getSkiaColorMatrix(baseFilter: BaseFilter, adj: ColorAdjustments
matrix = multiplyMatrices(wbMat, matrix);
}
// 5b. Fuji WB fine-tune Red/Blue axis (the CREATE modal's R/B offsets). These
// axes are independent channel gains on top of the Kelvin/tint preset: each
// step nudges its channel ~1.1% toward/away from neutral, matching the
// perceptible size of one Fuji fine-tune step (-9..+9).
const wbRed = adj.wbRed ?? 0;
const wbBlue = adj.wbBlue ?? 0;
if (baseFilter !== 'monochrome' && (wbRed !== 0 || wbBlue !== 0)) {
const shiftMat = [
1 + (wbRed / 9) * 0.1, 0, 0, 0, 0,
0, 1, 0, 0, 0,
0, 0, 1 + (wbBlue / 9) * 0.1, 0, 0,
0, 0, 0, 1, 0,
];
matrix = multiplyMatrices(shiftMat, matrix);
}
// 6. Exposure & Contrast — applied to ALL base filters (monochrome included) so
// both keep affecting mono shots. Contrast scales around mid-grey 0.5.
if (exposure !== 0 || contrast !== 0) {
+36
View File
@@ -9,6 +9,8 @@ export const DEFAULT_ADJUSTMENTS: ColorAdjustments = {
saturation: 0,
temperature: 5500,
tint: 0,
wbRed: 0,
wbBlue: 0,
highlight: 0,
shadow: 0,
denoise: 0,
@@ -21,6 +23,40 @@ export const DEFAULT_ADJUSTMENTS: ColorAdjustments = {
exposureCompensation: 0,
};
// Standard Fuji film simulations. These are the fixed "look" presets shown at
// the front of the PRESETS chip row: tapping one applies the simulation via
// its baseFilter colour matrix ONLY — every other knob resets to its neutral
// default (0 / auto) so the sim is a clean starting point, never a baked
// recipe. Any look beyond the matrix (grain, DR, CC, contrast…) is added by
// the user afterwards.
export interface FilmSim {
id: string; // Recipe-like id used only while the sim is the current selection
name: string;
baseFilter: Recipe['baseFilter'];
adjustments: ColorAdjustments;
}
export const FILM_SIMS: FilmSim[] = [
{ id: 'sim-provia', name: 'PROVIA', baseFilter: 'provia', adjustments: { ...DEFAULT_ADJUSTMENTS } },
{ id: 'sim-velvia', name: 'VELVIA', baseFilter: 'velvia', adjustments: { ...DEFAULT_ADJUSTMENTS } },
{ id: 'sim-classic-chrome', name: 'CLASSIC CHROME', baseFilter: 'classic-chrome', adjustments: { ...DEFAULT_ADJUSTMENTS } },
{ id: 'sim-astia', name: 'ASTIA', baseFilter: 'astia', adjustments: { ...DEFAULT_ADJUSTMENTS } },
{ id: 'sim-eterna', name: 'ETERNA', baseFilter: 'eterna', adjustments: { ...DEFAULT_ADJUSTMENTS } },
{ id: 'sim-acros', name: 'ACROS', baseFilter: 'monochrome', adjustments: { ...DEFAULT_ADJUSTMENTS } },
];
// Film sim → full Recipe (never persisted; sims are applied on the fly).
export function filmSimToRecipe(sim: FilmSim): Recipe {
return {
id: sim.id,
name: sim.name,
baseFilter: sim.baseFilter,
adjustments: { ...sim.adjustments },
frameId: 'none',
useGeotag: true,
};
}
export const DEFAULT_RECIPES: Recipe[] = [
{
id: 'classic-neg-default',
+16 -37
View File
@@ -13,6 +13,9 @@ import { patchJpegDpi } from './jpegDpi';
export interface ExportResult {
uri: string;
savedToLibrary: boolean;
// MediaLibrary asset id when the photo was saved to the gallery — lets the
// photo viewer's trash button remove the gallery copy too.
assetId?: string | null;
}
export interface ExportOptions {
@@ -69,9 +72,7 @@ function screenSharpenImage(image: SkImage, amount = 0.5): SkImage {
// Unique filename per export. A fixed path made every export share one URI, and
// RN's <Image> cache (keyed by URI) then kept showing the previous photo in the
// thumbnail after a new capture overwrote the file. A fresh URI per export busts
// that cache. Previous export file is deleted after the new one is written and
// gallery-saved, so at most one stale temp file can ever exist.
let lastExportPath: string | null = null;
// that cache.
const nextExportFile = () => `${FileSystem.cacheDirectory}camrecipe_pro_export_${Date.now()}.jpg`;
export async function processAndExportPhoto(
@@ -82,10 +83,6 @@ export async function processAndExportPhoto(
gpsInfo: GPSInfo | null,
options?: ExportOptions
): Promise<ExportResult | null> {
const t0 = Date.now();
// Direct console.error templates (user log captures are E-level only). Steps
// use full static prefixes — release minification dropped step literals when
// they were passed through a wrapper, so no indirection here.
try {
// 1. Read source image file into Skia
const skiaData = await Skia.Data.fromURI(sourceUri);
@@ -99,13 +96,9 @@ export async function processAndExportPhoto(
console.error('Failed to parse image from URI');
return null;
}
console.error(`[export] decode ${Date.now() - t0}ms`);
let skImage = skImage0;
let width = skImage.width();
let height = skImage.height();
console.error(`[export] BEGIN ${width}x${height} ev=${recipe.adjustments.exposureCompensation ?? 0} evFromCamera=${options?.evFromCamera ?? false}`);
// 1b. Aspect-ratio crop (composition): center-largest upright crop of the
// requested ratio. Runs BEFORE any processing so frame/GPS/grain/sharpen
// all apply to exactly the pixels the user framed. Wall Frame is exempt —
@@ -144,7 +137,6 @@ export async function processAndExportPhoto(
skImage = cropped;
width = cropW;
height = cropH;
console.error(`[export] aspect-crop ${width}x${height}`);
}
// 2. Create offscreen canvas (GPU-backed when available — see createSurface)
@@ -181,7 +173,7 @@ export async function processAndExportPhoto(
try {
toneEffect = Skia.RuntimeEffect.Make(TONE_SKSL);
} catch (e) {
console.error('[export] TONE_SKSL compile failed - falling back to matrix-only: ' + e);
console.error('TONE_SKSL compile failed - falling back to matrix-only: ' + e);
}
if (toneEffect) {
const imageShader = skImage.makeShaderOptions(
@@ -194,10 +186,10 @@ export async function processAndExportPhoto(
if (toneShader) {
paint.setShader(toneShader);
} else {
console.error('[export] tone shader build failed - falling back to matrix-only');
console.error('Tone shader build failed - falling back to matrix-only');
}
} else {
console.error('[export] TONE_SKSL compile failed - falling back to matrix-only');
console.error('TONE_SKSL compile failed - falling back to matrix-only');
}
}
@@ -222,7 +214,7 @@ export async function processAndExportPhoto(
}
}
} catch (e) {
console.error('[export] CINEMA_SKSL failed - skipped: ' + e);
console.error('CINEMA_SKSL failed - skipped: ' + e);
}
}
@@ -462,7 +454,6 @@ export async function processAndExportPhoto(
}
// 9. Snapshot & Encode to JPEG (base64 string, no SkData round-trip)
console.error(`[export] composite ${Date.now() - t0}ms`);
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.
@@ -471,7 +462,6 @@ export async function processAndExportPhoto(
if (sharpenAmount > 0) {
resultImage = screenSharpenImage(resultImage, sharpenAmount);
}
console.error(`[export] sharpen ${Date.now() - t0}ms`);
// Encode straight to raw JPEG bytes — no base64. The old path round-tripped
// the whole 12MP frame through JS base64 decode/encode for the DPI patch and
// then writeAsStringAsync, which alone took ~6s of the render.
@@ -481,8 +471,6 @@ export async function processAndExportPhoto(
console.error('Failed to encode image to JPEG');
return null;
}
console.error(`[export] encode ${Date.now() - t0}ms`);
// 9b. Patch DPI metadata (JFIF density / EXIF resolution) when requested —
// in-place Uint8Array work, no base64 round-trip.
if (options?.dpi && options.dpi > 0) {
@@ -499,32 +487,23 @@ export async function processAndExportPhoto(
console.error('Failed to write export file: ' + e);
return null;
}
console.error(`[export] write ${Date.now() - t0}ms`);
console.error(`[export] total-done ${Date.now() - t0}ms`);
// 11. Request Media Library permission & Save to device gallery
// 11. Request Media Library permission & Save to device gallery.
// createAssetAsync (not saveToLibraryAsync) so the caller learns the
// gallery asset id — the photo viewer's trash button needs it to remove
// the saved copy. ponytail: old exports are NOT retired here anymore — the
// in-app photo history still references their cache files for the swipe
// viewer; the OS cache purge reclaims them eventually.
const mediaPermission = await MediaLibrary.requestPermissionsAsync();
if (mediaPermission.granted) {
try {
await MediaLibrary.saveToLibraryAsync(exportFile);
// New file is safely in the gallery — retire the previous export temp.
if (lastExportPath && lastExportPath !== exportFile) {
try {
await FileSystem.deleteAsync(lastExportPath, { idempotent: true });
} catch (e) {
console.warn('Cleanup of previous export temp failed:', e);
}
}
lastExportPath = exportFile;
return { uri: exportFile, savedToLibrary: true };
const asset = await MediaLibrary.createAssetAsync(exportFile);
return { uri: exportFile, savedToLibrary: true, assetId: asset.id };
} catch (e) {
console.warn('Save to gallery failed:', e);
}
} else {
console.warn('Media Library permission denied. Image saved to temporary cache only.');
}
console.error(`[export] gallery-save ${Date.now() - t0}ms`);
return { uri: exportFile, savedToLibrary: false };
} catch (error) {
console.error('Error during photo processing and export:', error);
+165
View File
@@ -0,0 +1,165 @@
import { ColorAdjustments } from '../types';
// Static metadata for the continuous (slider) adjustments of the IQ/WB/FILTERS
// tabs — shared between AdjustmentPanel (chips + slider rows) and App (which
// needs the open row's bounds to drive drag-on-the-image adjustment). The get
// /set accessors are stateless so one table serves every render.
export interface ContinuousParamDef {
key: string;
label: string;
min: number;
max: number;
step: number;
defaultValue: number;
display: (v: number) => string;
get: (a: ColorAdjustments) => number;
set: (v: number) => Partial<ColorAdjustments>;
}
const sign = (v: number) => (v > 0 ? `+${v}` : String(v));
const oneEv = (v: number) => `${v > 0 ? '+' : ''}${v.toFixed(1)} EV`;
export const PARAM_DEFS: {
iq: ContinuousParamDef[];
wb: ContinuousParamDef[];
filters: ContinuousParamDef[];
} = {
iq: [
{
key: 'exposure',
label: 'EXPOSURE',
min: -10,
max: 10,
step: 1,
defaultValue: 0,
display: sign,
get: (a) => a.exposure,
set: (v) => ({ exposure: v }),
},
{
key: 'ev',
label: 'EV',
min: -3,
max: 3,
step: 0.5,
defaultValue: 0,
display: oneEv,
get: (a) => a.exposureCompensation ?? 0,
set: (v) => ({ exposureCompensation: v }),
},
{
key: 'contrast',
label: 'CONTRAST',
min: -10,
max: 10,
step: 1,
defaultValue: 0,
display: sign,
get: (a) => a.contrast,
set: (v) => ({ contrast: v }),
},
{
key: 'color',
label: 'COLOR',
min: -10,
max: 10,
step: 1,
defaultValue: 0,
display: sign,
get: (a) => a.saturation,
set: (v) => ({ saturation: v }),
},
{
key: 'highlight',
label: 'HIGHLIGHT',
min: -10,
max: 10,
step: 1,
defaultValue: 0,
display: sign,
get: (a) => a.highlight ?? 0,
set: (v) => ({ highlight: v }),
},
{
key: 'shadow',
label: 'SHADOW',
min: -10,
max: 10,
step: 1,
defaultValue: 0,
display: sign,
get: (a) => a.shadow ?? 0,
set: (v) => ({ shadow: v }),
},
],
wb: [
{
key: 'temperature',
label: 'COLOR TEMP',
min: 2500,
max: 10000,
step: 100,
defaultValue: 5500,
display: (v) => `${v}K`,
get: (a) => a.temperature,
set: (v) => ({ temperature: v }),
},
{
key: 'tint',
label: 'TINT',
min: -10,
max: 10,
step: 1,
defaultValue: 0,
display: sign,
get: (a) => a.tint ?? 0,
set: (v) => ({ tint: v }),
},
],
filters: [
{
key: 'denoise',
label: 'NOISE REDUCTION',
min: 0,
max: 10,
step: 1,
defaultValue: 0,
display: String,
get: (a) => a.denoise,
set: (v) => ({ denoise: v }),
},
{
key: 'clarity',
label: 'CLARITY',
min: -10,
max: 10,
step: 1,
defaultValue: 0,
display: sign,
get: (a) => a.clarity,
set: (v) => ({ clarity: v }),
},
{
key: 'sharpening',
label: 'SHARPENING',
min: 0,
max: 10,
step: 1,
defaultValue: 0,
display: String,
get: (a) => a.sharpening ?? 0,
set: (v) => ({ sharpening: v }),
},
{
key: 'grain',
label: 'MONOCHROME GRAIN',
min: 0,
max: 10,
step: 1,
defaultValue: 0,
display: String,
get: (a) => a.grain,
set: (v) => ({ grain: v }),
},
],
};
+7 -10
View File
@@ -52,16 +52,16 @@ async function resolveFromMediaLibrary(
try {
perm = await MediaLibrary.requestPermissionsAsync(false, ['photo']);
} catch (e) {
console.error('[gps] tier2 MediaLibrary permission request failed: ' + String(e).slice(0, 120));
console.error('Media library permission request failed (photo GPS): ' + String(e).slice(0, 120));
return { gps: null, permissionDenied: true };
}
if (!perm.granted) {
console.error('[gps] tier2 MediaLibrary permission denied');
console.error('Media library read permission denied (photo GPS)');
return { gps: null, permissionDenied: true };
}
const id = pickerAssetId(asset);
if (!id) {
console.error('[gps] tier2 no assetId to query MediaStore');
console.warn('Photo has no media-library asset id — GPS lookup skipped');
return { gps: null, permissionDenied: false };
}
try {
@@ -69,7 +69,6 @@ async function resolveFromMediaLibrary(
if (info.location && Number.isFinite(info.location.latitude) && Number.isFinite(info.location.longitude)) {
const { latitude, longitude } = info.location;
const locality = useGeotag ? await localityFor(latitude, longitude) : undefined;
console.error(`[gps] tier2 MediaStore location lat=${latitude.toFixed(5)} lon=${longitude.toFixed(5)}`);
return {
gps: {
latitude,
@@ -82,9 +81,9 @@ async function resolveFromMediaLibrary(
permissionDenied: false,
};
}
console.error('[gps] tier2 MediaStore asset has no location (photo may have no GPS, or ACCESS_MEDIA_LOCATION read failed silently)');
console.warn('Media library photo has no location (no GPS, or ACCESS_MEDIA_LOCATION denied silently)');
} catch (e) {
console.error('[gps] tier2 getAssetInfoAsync failed: ' + String(e).slice(0, 160));
console.error('Media library asset lookup failed (photo GPS): ' + String(e).slice(0, 160));
}
return { gps: null, permissionDenied: false };
}
@@ -102,12 +101,11 @@ export async function resolvePhotoGPS(
if (exifCoord) {
const timestamp = parseExifTimestamp(asset.exif) ?? Date.now();
const locality = useGeotag ? await localityFor(exifCoord.latitude, exifCoord.longitude) : undefined;
console.error(`[gps] tier1 EXIF lat=${exifCoord.latitude.toFixed(5)} lon=${exifCoord.longitude.toFixed(5)}`);
return {
gps: { ...exifCoord, locality, country: undefined, timestamp, source: 'exif' },
};
}
console.error('[gps] tier1 no EXIF GPS in picker metadata');
console.warn('No EXIF GPS in picker metadata');
// Tier 2 — MediaLibrary metadata (Android 10+ requires ACCESS_MEDIA_LOCATION)
const assetId = pickerAssetId(asset);
@@ -116,14 +114,13 @@ export async function resolvePhotoGPS(
if (gps) return { gps };
if (permissionDenied) return { gps: null, permissionDenied: true };
} else {
console.error('[gps] tier2 skipped — no asset id/uri');
console.warn('Photo GPS tier-2 skipped — no asset id/uri');
}
// Tier 3 — device fallback (photo has no location; stamp the device's current spot)
if (allowDeviceFallback) {
const deviceGps = await getCurrentGPS();
if (deviceGps) {
console.error(`[gps] tier3 DEVICE GPS fallback lat=${deviceGps.latitude.toFixed(5)} lon=${deviceGps.longitude.toFixed(5)}`);
return { gps: { ...deviceGps, source: 'device' } };
}
}