feat(wm): GPS watermark color + place-name/time toggles, "commune, city" locality
This commit is contained in:
@@ -89,7 +89,14 @@ export default function App() {
|
||||
// GPS mark placement (WATERMARK tab drag + pinch): fractions of the frame /
|
||||
// photo area plus a size multiplier, same space as the custom mark. There is
|
||||
// nothing to accept — the panel being open IS the edit session.
|
||||
const DEFAULT_GPS_WM: GpsWatermark = { x: 0.05, y: 0.1, size: 1 };
|
||||
const DEFAULT_GPS_WM: GpsWatermark = {
|
||||
x: 0.05,
|
||||
y: 0.1,
|
||||
size: 1,
|
||||
color: '#f59e0b',
|
||||
showName: true,
|
||||
showTime: true,
|
||||
};
|
||||
const [gpsWm, setGpsWm] = useState<GpsWatermark>({ ...DEFAULT_GPS_WM });
|
||||
// FRAME tab ORIENTATION chip (WALL FRAME only): hang the artwork landscape
|
||||
// (the PNG's own 4000x3117) instead of the default portrait rotation.
|
||||
@@ -276,6 +283,7 @@ export default function App() {
|
||||
selectedRecipeId: selectedRecipe?.id || null,
|
||||
gpsInfo,
|
||||
customWm,
|
||||
gpsWm,
|
||||
};
|
||||
try {
|
||||
await AsyncStorage.setItem(SESSION_KEY, JSON.stringify(session));
|
||||
@@ -285,7 +293,7 @@ export default function App() {
|
||||
}
|
||||
});
|
||||
return () => subscription.remove();
|
||||
}, [mode, libraryImageUri, adjustments, selectedFrame, useGeotag, selectedRecipe, gpsInfo, customWm, aspectRatio, meterMode, shutterSound]);
|
||||
}, [mode, libraryImageUri, adjustments, selectedFrame, useGeotag, selectedRecipe, gpsInfo, customWm, gpsWm, aspectRatio, meterMode, shutterSound]);
|
||||
|
||||
// Guards the camera-settings persist effect below: until initSession has
|
||||
// read the stored values, changes are just the initial defaults — writing
|
||||
@@ -392,6 +400,18 @@ export default function App() {
|
||||
font: typeof raw.font === 'string' ? raw.font : null,
|
||||
});
|
||||
}
|
||||
if (session.gpsWm && typeof session.gpsWm === 'object') {
|
||||
// Style chips only — the placement fractions are left alone (restoring
|
||||
// a stale x/y would silently move the stamp the user just dragged).
|
||||
const raw = session.gpsWm as Partial<GpsWatermark>;
|
||||
const keep = (v: unknown, fb: boolean) => (typeof v === 'boolean' ? v : fb);
|
||||
setGpsWm((g) => ({
|
||||
...g,
|
||||
color: typeof raw.color === 'string' ? raw.color : g.color,
|
||||
showName: keep(raw.showName, true),
|
||||
showTime: keep(raw.showTime, true),
|
||||
}));
|
||||
}
|
||||
if (session.selectedRecipeId) {
|
||||
// Film sims are never persisted in the recipe list (sims applied on the
|
||||
// fly) — resolve sim ids via FILM_SIMS, real recipes via the list.
|
||||
@@ -1083,7 +1103,7 @@ export default function App() {
|
||||
customWm={customWm}
|
||||
wmRotation={wmRotation}
|
||||
gpsWm={gpsWm}
|
||||
onGpsWmChange={(x, y, size) => setGpsWm({ x, y, size })}
|
||||
onGpsWmChange={(x, y, size) => setGpsWm((g) => ({ ...g, x, y, size }))}
|
||||
onWmSizeChange={(size) => setCustomWm((p) => ({ ...p, size }))}
|
||||
wallLandscape={wallLandscape}
|
||||
// The mark only captures image touches while its panel is open, so a
|
||||
@@ -1141,6 +1161,8 @@ export default function App() {
|
||||
onDeleteRecipe={handleDeleteRecipe}
|
||||
customWm={customWm}
|
||||
onUpdateCustomWm={handleUpdateCustomWm}
|
||||
gpsWm={gpsWm}
|
||||
onUpdateGpsWm={setGpsWm}
|
||||
onAcceptCustomWm={handleAcceptCustomWm}
|
||||
onCancelCustomWm={handleCancelCustomWm}
|
||||
wmEditNonce={wmEditNonce}
|
||||
|
||||
@@ -4,7 +4,7 @@ import Slider from '@react-native-community/slider';
|
||||
import * as Haptics from 'expo-haptics';
|
||||
import { Trash2 } from 'lucide-react-native';
|
||||
import { Skia } from '@shopify/react-native-skia';
|
||||
import { Recipe, ColorAdjustments, CustomWatermark, FrameId, BaseFilter } from '../types';
|
||||
import { Recipe, ColorAdjustments, CustomWatermark, GpsWatermark, FrameId, BaseFilter } from '../types';
|
||||
import { FRAMES } from '../utils/frameUtils';
|
||||
import { PARAM_DEFS, ContinuousParamDef } from '../utils/paramDefs';
|
||||
import { FILM_SIMS, filmSimToRecipe } from '../utils/defaultRecipes';
|
||||
@@ -19,7 +19,16 @@ import RecipeCreateModal from './RecipeCreateModal';
|
||||
type TabId = 'recipes' | 'iq' | 'wb' | 'filters' | 'frame' | 'watermark';
|
||||
// Choice-group keys that open a strip above the chip row: color chrome groups
|
||||
// (wb tab) and the D.RANGE selector (iq tab).
|
||||
type GroupKey = 'cx' | 'cxb' | 'dr' | 'wmColor' | 'wmSize' | 'wmFont';
|
||||
type GroupKey =
|
||||
| 'cx'
|
||||
| 'cxb'
|
||||
| 'dr'
|
||||
| 'wmColor'
|
||||
| 'wmSize'
|
||||
| 'wmFont'
|
||||
| 'gpsColor'
|
||||
| 'gpsName'
|
||||
| 'gpsTime';
|
||||
|
||||
interface AdjustmentPanelProps {
|
||||
activeTab: TabId;
|
||||
@@ -59,6 +68,10 @@ interface AdjustmentPanelProps {
|
||||
// input and the export options all read the same object.
|
||||
customWm: CustomWatermark;
|
||||
onUpdateCustomWm: (wm: CustomWatermark) => void;
|
||||
// GPS mark styling — the SAME object the preview and the export read, so a
|
||||
// chip press repaints both.
|
||||
gpsWm: GpsWatermark;
|
||||
onUpdateGpsWm: (wm: GpsWatermark) => void;
|
||||
// ACCEPT: copy the live draft into the mark the export burns in. Edits stay a
|
||||
// draft (viewfinder preview only) until this is pressed.
|
||||
onAcceptCustomWm: () => void;
|
||||
@@ -152,6 +165,8 @@ export default function AdjustmentPanel({
|
||||
onDeleteRecipe,
|
||||
customWm,
|
||||
onUpdateCustomWm,
|
||||
gpsWm,
|
||||
onUpdateGpsWm,
|
||||
onAcceptCustomWm,
|
||||
onCancelCustomWm,
|
||||
wmEditNonce,
|
||||
@@ -388,6 +403,25 @@ export default function AdjustmentPanel({
|
||||
options: wmFontOptions,
|
||||
onPick: (v) => onUpdateCustomWm({ ...customWm, font: v === 'DEFAULT' ? null : v }),
|
||||
},
|
||||
// GPS mark's own features — one chip each, opening its options above (the
|
||||
// same one-row-at-a-time strip the custom mark's styling uses).
|
||||
gpsColor: {
|
||||
label: 'COLOR', off: WM_DEFAULT_COLOR,
|
||||
value: gpsWm.color ?? WM_DEFAULT_COLOR, options: WM_COLORS,
|
||||
onPick: (v) => onUpdateGpsWm({ ...gpsWm, color: v }),
|
||||
},
|
||||
gpsName: {
|
||||
label: 'PLACE NAME', off: 'ON',
|
||||
value: gpsWm.showName === false ? 'OFF' : 'ON',
|
||||
options: [{ v: 'on', d: 'ON' }, { v: 'off', d: 'OFF' }],
|
||||
onPick: (v) => onUpdateGpsWm({ ...gpsWm, showName: v === 'on' }),
|
||||
},
|
||||
gpsTime: {
|
||||
label: 'TIME', off: 'ON',
|
||||
value: gpsWm.showTime === false ? 'OFF' : 'ON',
|
||||
options: [{ v: 'on', d: 'ON' }, { v: 'off', d: 'OFF' }],
|
||||
onPick: (v) => onUpdateGpsWm({ ...gpsWm, showTime: v === 'on' }),
|
||||
},
|
||||
};
|
||||
const groupChip = (g: GroupKey): ChipDef => ({
|
||||
key: `grp-${g}`,
|
||||
@@ -537,6 +571,9 @@ export default function AdjustmentPanel({
|
||||
active: useGeotag,
|
||||
onPress: () => onToggleGeotag(!useGeotag),
|
||||
})}
|
||||
{useGeotag && renderChip(groupChip('gpsColor'))}
|
||||
{useGeotag && renderChip(groupChip('gpsName'))}
|
||||
{useGeotag && renderChip(groupChip('gpsTime'))}
|
||||
{renderChip({
|
||||
key: 'custom-wm',
|
||||
label: customWm.enabled ? 'CUSTOM WATERMARK ON' : 'CUSTOM WATERMARK OFF',
|
||||
|
||||
@@ -108,7 +108,7 @@ interface ViewfinderProps {
|
||||
// GPS mark placement (WATERMARK tab drag + pinch): the same 0..1 fractions
|
||||
// of the same area customWm uses, TOP-LEFT anchored; size multiplies the
|
||||
// base 3.2%-of-width size. Absent = the historical fixed top-left look.
|
||||
gpsWm?: { x: number; y: number; size: number };
|
||||
gpsWm?: { x: number; y: number; size: number; color?: string; showName?: boolean; showTime?: boolean };
|
||||
onGpsWmChange?: (x: number, y: number, size: number) => void;
|
||||
// Pinch on the custom mark: its SIZE, exactly like the panel's SIZE chip.
|
||||
onWmSizeChange?: (size: number) => void;
|
||||
@@ -1720,25 +1720,32 @@ const Viewfinder = forwardRef<ViewfinderHandle, ViewfinderProps>(function Viewfi
|
||||
const pad = (n: number) => String(n).padStart(2, '0');
|
||||
const timestampStr = `${pad(d.getDate())}/${pad(d.getMonth() + 1)}/${d.getFullYear()} ${pad(d.getHours())}:${pad(d.getMinutes())}`;
|
||||
|
||||
// TOP-LEFT anchored two-line block at the dragged fraction of the same area
|
||||
// the custom mark uses (frame card / photo window / screen). A framed
|
||||
// export's canvas IS that frame, so the fractions carry straight to the
|
||||
// burn-in — exportEngine step 8 draws with the identical formula.
|
||||
// TOP-LEFT anchored block at the dragged fraction of the same area the
|
||||
// custom mark uses (frame card / photo window / screen). A framed export's
|
||||
// canvas IS that frame, so the fractions carry straight to the burn-in -
|
||||
// exportEngine step 8 draws with the identical formula. Each enabled line
|
||||
// (place name / shot time) stacks under the previous one.
|
||||
const lines: { icon: string; text: string }[] = [];
|
||||
if (gpsWm?.showName !== false) lines.push({ icon: '📍', text: locationName });
|
||||
if (gpsWm?.showTime !== false) lines.push({ icon: '📷', text: timestampStr });
|
||||
if (!lines.length) return null;
|
||||
|
||||
const { ax, ay, iconSize } = gpsAnchor();
|
||||
const row1Y = ay + Math.round(iconSize * 1.1);
|
||||
const row2Y = ay + Math.round(iconSize * 2.3);
|
||||
const textColor = gpsWm?.color ?? '#f59e0b';
|
||||
// One clear space (0.6em ~ a mono space) between each icon and its text.
|
||||
const gap = (w: number) => Math.round(Math.max(w, iconSize) + iconSize * 0.6);
|
||||
const pinW = gpsIconFont.measureText('📍');
|
||||
const camW = gpsIconFont.measureText('📷');
|
||||
// The old fixed row1/row2 spacing, generalised to N rows.
|
||||
const rowY = (i: number) => ay + Math.round(iconSize * (1.1 + i * 1.2));
|
||||
|
||||
return (
|
||||
<Group origin={{ x: ax, y: ay }} transform={wmRotation ? [{ rotate: wmRad }] : undefined}>
|
||||
{/* Icons white so they read as glyphs, not part of the amber text */}
|
||||
<SkiaText x={ax} y={row1Y} text="📍" font={gpsIconFont} color="#ffffff" />
|
||||
<SkiaText x={ax + gap(pinW.width)} y={row1Y} text={locationName} font={gpsFont} color="#f59e0b" />
|
||||
<SkiaText x={ax} y={row2Y} text="📷" font={gpsIconFont} color="#ffffff" />
|
||||
<SkiaText x={ax + gap(camW.width)} y={row2Y} text={timestampStr} font={gpsFont} color="#f59e0b" />
|
||||
{lines.map((line, i) => (
|
||||
<React.Fragment key={line.icon}>
|
||||
{/* Icons white so they read as glyphs, not part of the coloured text */}
|
||||
<SkiaText x={ax} y={rowY(i)} text={line.icon} font={gpsIconFont} color="#ffffff" />
|
||||
<SkiaText x={ax + gap(gpsIconFont.measureText(line.icon).width)} y={rowY(i)} text={line.text} font={gpsFont} color={textColor} />
|
||||
</React.Fragment>
|
||||
))}
|
||||
</Group>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -106,4 +106,10 @@ export interface GpsWatermark {
|
||||
x: number;
|
||||
y: number;
|
||||
size: number;
|
||||
// Styling set by the WATERMARK tab's PLACE NAME / TIME / COLOR chips. Absent
|
||||
// on marks saved before those controls existed — the renderers then fall back
|
||||
// to amber text with both lines shown, i.e. exactly the previous look.
|
||||
color?: string;
|
||||
showName?: boolean;
|
||||
showTime?: boolean;
|
||||
}
|
||||
|
||||
@@ -53,7 +53,7 @@ export interface ExportOptions {
|
||||
// WATERMARK tab drag + pinch on the GPS lines: 0..1 fractions of the same
|
||||
// area the custom mark uses (a framed export's canvas IS the frame) plus a
|
||||
// multiplier on the base 3.2%-of-width size. Absent = the old top-left look.
|
||||
gpsWatermark?: { x: number; y: number; size: number } | null;
|
||||
gpsWatermark?: { x: number; y: number; size: number; color?: string; showName?: boolean; showTime?: boolean } | null;
|
||||
// WALL FRAME hung the other way round (4000x3117, artwork un-rotated).
|
||||
wallframeLandscape?: boolean;
|
||||
// Library framed-window photo reposition (Viewfinder.getFrameWindowZoom):
|
||||
@@ -606,18 +606,23 @@ export async function processAndExportPhoto(
|
||||
const gpsGap = Math.round(gpsSize * 1.6); // icon box (~1em) + one clear mono space (~0.6em)
|
||||
const baseX = clamp01(gpsPos?.x ?? 0.05) * stampW;
|
||||
const baseY = clamp01(gpsPos?.y ?? 0.1) * stampH;
|
||||
const row1Y = baseY + Math.round(gpsSize * 1.1);
|
||||
const row2Y = baseY + Math.round(gpsSize * 2.3);
|
||||
// Each enabled line (place name / shot time) stacks under the
|
||||
// previous one, exactly as the viewfinder draws it.
|
||||
const lines: { icon: string; text: string }[] = [];
|
||||
if (gpsPos?.showName !== false) lines.push({ icon: '📍', text: locationName });
|
||||
if (gpsPos?.showTime !== false) lines.push({ icon: '📷', text: timestampStr });
|
||||
textPaint.setColor(Skia.Color(gpsPos?.color ?? '#f59e0b')); // user-chosen, amber by default
|
||||
|
||||
// Spin the whole two-line block around its top-left (the anchor the
|
||||
// Spin the whole block around its top-left (the anchor the
|
||||
// viewfinder uses). 0 = the historical upright look.
|
||||
const wmRot = options?.watermarkRotation ?? 0;
|
||||
stampCanvas.save();
|
||||
if (wmRot) stampCanvas.rotate(wmRot, baseX, baseY);
|
||||
stampCanvas.drawText('📍', baseX, row1Y, iconPaint, gpsIconFont);
|
||||
stampCanvas.drawText(locationName, baseX + gpsGap, row1Y, textPaint, gpsFont);
|
||||
stampCanvas.drawText('📷', baseX, row2Y, iconPaint, gpsIconFont);
|
||||
stampCanvas.drawText(timestampStr, baseX + gpsGap, row2Y, textPaint, gpsFont);
|
||||
lines.forEach((line, i) => {
|
||||
const rowY = baseY + Math.round(gpsSize * (1.1 + i * 1.2));
|
||||
stampCanvas.drawText(line.icon, baseX, rowY, iconPaint, gpsIconFont);
|
||||
stampCanvas.drawText(line.text, baseX + gpsGap, rowY, textPaint, gpsFont);
|
||||
});
|
||||
stampCanvas.restore();
|
||||
}
|
||||
|
||||
|
||||
@@ -49,12 +49,17 @@ export async function getCurrentGPS(): Promise<GPSInfo | null> {
|
||||
}
|
||||
}
|
||||
|
||||
// The one place-name picker: city first, because "the place" on a stamp means
|
||||
// the town people would name, while subregion is Android's subAdminArea — the
|
||||
// county/district, i.e. the wrong name for the same coordinates. Shared so the
|
||||
// camera stamp and a photo's stamp can never disagree.
|
||||
// The one place-name picker, shared so the camera stamp and a photo's stamp can
|
||||
// never disagree. A Vietnamese geocoder answers with the commune/ward in
|
||||
// subregion (post-2025 two-level administration: the commune is the district-
|
||||
// level name) and the province/city in region, while city and district come
|
||||
// back null, so the stamp reads "COMMUNE, CITY" instead of a bare city.
|
||||
export function localityName(address: Location.LocationGeocodedAddress): string | null {
|
||||
const name = address.city || address.subregion || address.district || address.region;
|
||||
const district = address.subregion || address.district;
|
||||
const city = address.city || address.region;
|
||||
// Same name in both slots (one field echoed twice) must not print twice.
|
||||
const parts = district && district !== city ? [district, city] : [district || city];
|
||||
const name = parts.filter(Boolean).join(', ');
|
||||
return name ? name.toUpperCase() : null;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user