feat: v1.1 — highlight-weighted metering, settings modal, photo viewer, shutter sound control, cleanup
This commit is contained in:
+42
-3
@@ -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) {
|
||||
|
||||
@@ -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
@@ -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);
|
||||
|
||||
@@ -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
@@ -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' } };
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user